Files

4163 lines
171 KiB
Go

package mysql
import (
"cmp"
"context"
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAndroid(t *testing.T) {
ds := CreateMySQLDS(t)
TruncateTables(t, ds)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"NewAndroidHost", testNewAndroidHost},
{"NewAndroidHostDedupesOrbitEnrolled", testNewAndroidHostDedupesOrbitEnrolled},
{"UpdateAndroidHost", testUpdateAndroidHost},
{"AndroidMDMStats", testAndroidMDMStats},
{"AndroidHostStorageData", testAndroidHostStorageData},
{"NewMDMAndroidConfigProfile", testNewMDMAndroidConfigProfile},
{"GetMDMAndroidConfigProfile", testGetMDMAndroidConfigProfile},
{"UpdateMDMAndroidConfigProfile", testUpdateMDMAndroidConfigProfile},
{"DeleteMDMAndroidConfigProfile", testDeleteMDMAndroidConfigProfile},
{"GetMDMAndroidProfilesSummary", testMDMAndroidProfilesSummary},
{"ListMDMAndroidProfilesToSend", testListMDMAndroidProfilesToSend},
{"ListMDMAndroidProfilesToSend_WithExcludeAny", testListMDMAndroidProfilesToSendWithExcludeAny},
{"ListMDMAndroidProfilesToSend_WithCombinedLabels", testListMDMAndroidProfilesToSendWithCombinedLabels},
{"ListMDMAndroidProfilesToSend_ExcludeAnyUnknownLabelPreservation", testListMDMAndroidProfilesToSendExcludeAnyUnknownLabelPreservation},
{"ListMDMAndroidProfilesToSend_IncludeAllUnknownLabelPreservation", testListMDMAndroidProfilesToSendIncludeAllUnknownLabelPreservation},
{"ListMDMAndroidProfilesToSend_CombinedUnknownLabelPreservation", testListMDMAndroidProfilesToSendCombinedUnknownLabelPreservation},
{"ListMDMAndroidProfilesToSend_Cursor", testListMDMAndroidProfilesToSendCursor},
{"GetMDMAndroidProfilesContents", testGetMDMAndroidProfilesContents},
{"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles},
{"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles2},
{"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles3},
{"GetHostMDMAndroidProfiles", testGetHostMDMAndroidProfiles},
{"GetAndroidPolicyRequestByUUID", testGetAndroidPolicyRequestByUUID},
{"MDMAndroidCommandCRUD", testMDMAndroidCommandCRUD},
{"ListPendingMDMAndroidCommands", testListPendingMDMAndroidCommands},
{"LockWipeHostViaAndroidMDM", testLockWipeHostViaAndroidMDM},
{"ListHostMDMAndroidProfilesPendingInstallWithVersion", testListHostMDMAndroidProfilesPendingInstallWithVersion},
{"BulkDeleteMDMAndroidHostProfiles", testBulkDeleteMDMAndroidHostProfiles},
{"BatchSetMDMAndroidProfiles_Associations", testBatchSetMDMAndroidProfiles_Associations},
{"NewAndroidHostWithIdP", testNewAndroidHostWithIdP},
{"AndroidBYODDetection", testAndroidBYODDetection},
{"SetAndroidHostUnenrolled", testSetAndroidHostUnenrolled},
{"SetAndroidHostEnrolled", testSetAndroidHostEnrolled},
{"AndroidPubSubDedupState", testAndroidPubSubDedupState},
{"BulkSetAndroidHostsUnenrolled", testBulkSetAndroidHostsUnenrolled},
{"InsertAndGetAndroidAppConfiguration", testInsertAndGetAndroidAppConfiguration},
{"UpdateAndroidAppConfiguration", testUpdateAndroidAppConfiguration},
{"DeleteAndroidAppConfiguration", testDeleteAndroidAppConfiguration},
{"GetAndroidAppConfiguration_NotFound", testGetAndroidAppConfigurationNotFound},
{"DeleteAndroidAppConfiguration_NotFound", testDeleteAndroidAppConfigurationNotFound},
{"AndroidAppConfiguration_CascadeDeleteTeam", testAndroidAppConfigurationCascadeDeleteTeam},
{"AndroidAppConfiguration_GlobalVsTeam", testAndroidAppConfigurationGlobalVsTeam},
{"AddDeleteAndroidAppWithConfiguration", testAddDeleteAndroidAppWithConfiguration},
{"HasAndroidAppConfigurationChanged", testHasAndroidAppConfigurationChanged},
{"UpdateTeamIDOnAndroidDevices", testUpdateTeamIDOnAndroidDevices},
{"GetAndroidDeviceLastTeamID", testGetAndroidDeviceLastTeamID},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testNewAndroidHost(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
const enterpriseSpecificID = "enterprise_specific_id"
host := createAndroidHost(enterpriseSpecificID)
result, err := ds.NewAndroidHost(testCtx(), host, false)
require.NoError(t, err)
assert.NotZero(t, result.Host.ID)
assert.NotZero(t, result.Device.ID)
lbls, err := ds.ListLabelsForHost(testCtx(), result.Host.ID)
require.NoError(t, err)
require.Len(t, lbls, 2)
names := []string{lbls[0].Name, lbls[1].Name}
require.ElementsMatch(t, []string{fleet.BuiltinLabelNameAllHosts, fleet.BuiltinLabelNameAndroid}, names)
resultLite, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
require.NoError(t, err)
assert.Equal(t, result.Host.ID, resultLite.Host.ID)
assert.Equal(t, result.Device.ID, resultLite.Device.ID)
resultLite, err = ds.AndroidHostLiteByHostUUID(testCtx(), result.Host.UUID)
require.NoError(t, err)
assert.Equal(t, result.Host.ID, resultLite.Host.ID)
assert.Equal(t, result.Device.ID, resultLite.Device.ID)
_, err = ds.AndroidHostLite(testCtx(), "non-existent")
require.Error(t, err)
_, err = ds.AndroidHostLiteByHostUUID(testCtx(), "no-such-host")
require.Error(t, err)
// Inserting the same host again should be fine.
// This may occur when 2 Fleet servers received the same host information via pubsub.
resultCopy, err := ds.NewAndroidHost(testCtx(), host, false)
require.NoError(t, err)
assert.Equal(t, result.Host.ID, resultCopy.Host.ID)
assert.Equal(t, result.Device.ID, resultCopy.Device.ID)
// create another host, this time delete the Android label
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(testCtx(), `DELETE FROM labels WHERE name = ?`, fleet.BuiltinLabelNameAndroid)
return err
})
const enterpriseSpecificID2 = "enterprise_specific_id2"
host2 := createAndroidHost(enterpriseSpecificID2)
// still passes, but no label membership was recorded
result, err = ds.NewAndroidHost(testCtx(), host2, false)
require.NoError(t, err)
lbls, err = ds.ListLabelsForHost(testCtx(), result.Host.ID)
require.NoError(t, err)
require.Empty(t, lbls)
}
// testNewAndroidHostDedupesOrbitEnrolled covers the duplicate-Android-hosts fix.
// The Fleet Android agent enrolls first via /api/fleet/orbit/enroll,
// then later the AMAPI pubsub flow delivers a STATUS_REPORT that lands in
// NewAndroidHost. The dedupe works whether the agent also sends
// platform="android" (newer agents) or leaves it blank (older agents).
func testNewAndroidHostDedupesOrbitEnrolled(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
cases := []struct {
name string
platform string
mdmEnabled bool
}{
{"agent sends no platform", "", true},
{"agent sends platform=android", "android", true},
{"agent sends platform=android, Apple MDM disabled", "android", false},
{"agent sends no platform, Apple MDM disabled", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := testCtx()
enterpriseSpecificID := strings.ToUpper(uuid.New().String())
orbitHost, err := ds.EnrollOrbit(ctx,
fleet.WithEnrollOrbitMDMEnabled(tc.mdmEnabled),
fleet.WithEnrollOrbitHostInfo(fleet.OrbitHostInfo{
HardwareUUID: enterpriseSpecificID,
HardwareSerial: enterpriseSpecificID,
Platform: tc.platform,
Hostname: "Samsung TestDevice",
ComputerName: "Samsung TestDevice",
HardwareModel: "TestModel",
}),
fleet.WithEnrollOrbitNodeKey(uuid.New().String()),
)
require.NoError(t, err)
require.NotZero(t, orbitHost.ID)
// Orbit enroll alone does not write an android_devices row; AndroidHostLite misses.
_, err = ds.AndroidHostLite(ctx, enterpriseSpecificID)
require.True(t, fleet.IsNotFound(err),
"before AMAPI arrives there is no android_devices row, so AndroidHostLite should miss")
// Simulate the AMAPI pubsub path calling NewAndroidHost. The fix makes
// NewAndroidHost find the existing orbit-enrolled hosts row by uuid and
// reuse it instead of inserting a duplicate.
newHost := createAndroidHost(enterpriseSpecificID)
returned, err := ds.NewAndroidHost(ctx, newHost, false)
require.NoError(t, err)
require.NotNil(t, returned)
require.Equal(t, orbitHost.ID, returned.Host.ID,
"NewAndroidHost must reuse the orbit-enrolled hosts row, not insert a duplicate")
// AndroidHostLite now finds the host via the newly-created android_devices row.
androidHost, err := ds.AndroidHostLite(ctx, enterpriseSpecificID)
require.NoError(t, err)
require.NotNil(t, androidHost)
require.Equal(t, orbitHost.ID, androidHost.Host.ID)
require.Equal(t, enterpriseSpecificID, androidHost.Host.UUID)
// Exactly one hosts row and one android_devices row for this device.
var hostCount, deviceCount int
require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &hostCount,
`SELECT COUNT(*) FROM hosts WHERE uuid = ?`, enterpriseSpecificID))
require.Equal(t, 1, hostCount)
require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &deviceCount,
`SELECT COUNT(*) FROM android_devices WHERE enterprise_specific_id = ?`, enterpriseSpecificID))
require.Equal(t, 1, deviceCount)
// Subsequent orbit re-enroll (agent node-key wipe, reinstall) stays idempotent.
_, err = ds.EnrollOrbit(ctx,
fleet.WithEnrollOrbitMDMEnabled(tc.mdmEnabled),
fleet.WithEnrollOrbitHostInfo(fleet.OrbitHostInfo{
HardwareUUID: enterpriseSpecificID,
HardwareSerial: enterpriseSpecificID,
Platform: tc.platform,
Hostname: "Samsung TestDevice",
ComputerName: "Samsung TestDevice",
HardwareModel: "TestModel",
}),
fleet.WithEnrollOrbitNodeKey(uuid.New().String()),
)
require.NoError(t, err)
require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &hostCount,
`SELECT COUNT(*) FROM hosts WHERE uuid = ?`, enterpriseSpecificID))
require.Equal(t, 1, hostCount)
require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &deviceCount,
`SELECT COUNT(*) FROM android_devices WHERE enterprise_specific_id = ?`, enterpriseSpecificID))
require.Equal(t, 1, deviceCount)
})
}
// Test the reverse flow: AMAPI enrolls first, then orbit joins. When Apple MDM is
// disabled, matchHostDuringEnrollment must still find the AMAPI-created host by UUID
// so orbit updates the existing row instead of inserting a duplicate.
for _, mdmEnabled := range []bool{true, false} {
name := "AMAPI first then orbit, Apple MDM enabled"
if !mdmEnabled {
name = "AMAPI first then orbit, Apple MDM disabled"
}
t.Run(name, func(t *testing.T) {
ctx := testCtx()
enterpriseSpecificID := strings.ToUpper(uuid.New().String())
// AMAPI creates the Android host first.
newHost := createAndroidHost(enterpriseSpecificID)
amAPIHost, err := ds.NewAndroidHost(ctx, newHost, false)
require.NoError(t, err)
require.NotZero(t, amAPIHost.Host.ID)
// Orbit enrollment should find the AMAPI-created host by UUID, not create a duplicate.
orbitHost, err := ds.EnrollOrbit(ctx,
fleet.WithEnrollOrbitMDMEnabled(mdmEnabled),
fleet.WithEnrollOrbitHostInfo(fleet.OrbitHostInfo{
HardwareUUID: enterpriseSpecificID,
HardwareSerial: enterpriseSpecificID,
Platform: "android",
Hostname: "Samsung TestDevice",
ComputerName: "Samsung TestDevice",
HardwareModel: "TestModel",
}),
fleet.WithEnrollOrbitNodeKey(uuid.New().String()),
)
require.NoError(t, err)
require.Equal(t, amAPIHost.Host.ID, orbitHost.ID,
"orbit enroll must reuse the AMAPI-created hosts row, not insert a duplicate")
// Still exactly one hosts row.
var hostCount int
require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &hostCount,
`SELECT COUNT(*) FROM hosts WHERE uuid = ?`, enterpriseSpecificID))
require.Equal(t, 1, hostCount)
})
}
// Two hosts already exist with the same uuid -- one orbit-enrolled
// (node_key=orbitKey) and one Android (node_key=android/<id>). A NewAndroidHost call
// with node_key=android/<id> must pick the Android row (not the orbit-enrolled one),
// otherwise the UPDATE would try to flip the orbit row's node_key to a value already
// held by the Android row and hit idx_host_unique_nodekey.
t.Run("Android orphan duplicates, prefers matching node_key", func(t *testing.T) {
ctx := testCtx()
enterpriseSpecificID := strings.ToUpper(uuid.New().String())
orbitHost, err := ds.EnrollOrbit(ctx,
fleet.WithEnrollOrbitMDMEnabled(true),
fleet.WithEnrollOrbitHostInfo(fleet.OrbitHostInfo{
HardwareUUID: enterpriseSpecificID,
HardwareSerial: enterpriseSpecificID,
Platform: "android",
Hostname: "orbit",
ComputerName: "orbit",
HardwareModel: "TestModel",
}),
fleet.WithEnrollOrbitNodeKey(uuid.New().String()),
)
require.NoError(t, err)
// Insert a second hosts row directly, with the same uuid but the Android-derived
// node_key, to simulate the duplicate state the dedupe must handle.
androidNodeKey := "android/" + enterpriseSpecificID
res, err := ds.writer(ctx).ExecContext(ctx,
`INSERT INTO hosts (node_key, uuid, platform, hostname, computer_name, hardware_serial,
detail_updated_at, label_updated_at, policy_updated_at)
VALUES (?, ?, 'android', 'android-dup', 'android-dup', 'serial-dup', NOW(), NOW(), NOW())`,
androidNodeKey, enterpriseSpecificID,
)
require.NoError(t, err)
androidDupID, err := res.LastInsertId()
require.NoError(t, err)
// NewAndroidHost must pick the existing Android row (not the orbit-enrolled one
// with the lower id). Otherwise the UPDATE would hit the UNIQUE node_key index.
newHost := createAndroidHost(enterpriseSpecificID)
require.Equal(t, androidNodeKey, *newHost.NodeKey,
"createAndroidHost is expected to build node_key=android/<uuid>")
returned, err := ds.NewAndroidHost(ctx, newHost, false)
require.NoError(t, err, "must not violate UNIQUE node_key when duplicate hosts share this uuid")
require.EqualValues(t, androidDupID, returned.Host.ID,
"NewAndroidHost should pick the row whose node_key matches, leaving the orbit-enrolled row alone")
require.NotEqual(t, orbitHost.ID, returned.Host.ID)
})
}
func createAndroidHost(enterpriseSpecificID string) *fleet.AndroidHost {
// Device ID needs to be unique per device
deviceID := md5ChecksumBytes([]byte(enterpriseSpecificID))[:16]
host := &fleet.AndroidHost{
Host: &fleet.Host{
Hostname: "hostname",
ComputerName: "computer_name",
Platform: "android",
OSVersion: "Android 14",
Build: "build",
Memory: 1024,
TeamID: nil,
HardwareSerial: "hardware_serial",
CPUType: "cpu_type",
HardwareModel: "hardware_model",
HardwareVendor: "hardware_vendor",
UUID: enterpriseSpecificID,
},
Device: &android.Device{
DeviceID: deviceID,
EnterpriseSpecificID: ptr.String(enterpriseSpecificID),
AppliedPolicyID: ptr.String("1"),
AppliedPolicyVersion: ptr.Int64(1),
LastPolicySyncTime: ptr.Time(time.Now().UTC().Truncate(time.Millisecond)),
},
}
host.SetNodeKey(enterpriseSpecificID)
return host
}
func testCtx() context.Context {
return context.Background()
}
func testUpdateAndroidHost(t *testing.T, ds *Datastore) {
const enterpriseSpecificID = "es_id_update"
host := createAndroidHost(enterpriseSpecificID)
result, err := ds.NewAndroidHost(testCtx(), host, false)
require.NoError(t, err)
assert.NotZero(t, result.Host.ID)
assert.NotZero(t, result.Device.ID)
// Dummy update
err = ds.UpdateAndroidHost(testCtx(), result, false, false)
require.NoError(t, err)
host = result
host.Host.DetailUpdatedAt = time.Now()
host.Host.LabelUpdatedAt = time.Now()
host.Host.Hostname = "hostname_updated"
host.Host.ComputerName = "computer_name_updated"
host.Host.Platform = "android_updated"
host.Host.OSVersion = "Android 15"
host.Host.Build = "build_updated"
host.Host.Memory = 2048
host.Host.HardwareSerial = "hardware_serial_updated"
host.Host.CPUType = "cpu_type_updated"
host.Host.HardwareModel = "hardware_model_updated"
host.Host.HardwareVendor = "hardware_vendor_updated"
host.Device.AppliedPolicyID = ptr.String("2")
// Make sure host UUID is preserved during update
host.Host.UUID = enterpriseSpecificID
err = ds.UpdateAndroidHost(testCtx(), host, false, false)
require.NoError(t, err)
resultLite, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
require.NoError(t, err)
assert.Equal(t, host.Host.ID, resultLite.Host.ID)
assert.EqualValues(t, host.Device, resultLite.Device)
// Make sure UUID was preserved after update
assert.Equal(t, enterpriseSpecificID, resultLite.Host.UUID, "UUID should be preserved after UpdateAndroidHost")
// Regression: empty UUID doesn't corrupt existing data
// This simulates a scenario where updateHost might not set UUID, resulting in empty value
t.Run("Empty UUID regression test", func(t *testing.T) {
const regressionESID = "regression-uuid-test"
regressionHost := createAndroidHost(regressionESID)
createdHost, err := ds.NewAndroidHost(testCtx(), regressionHost, false)
require.NoError(t, err)
require.Equal(t, regressionESID, createdHost.Host.UUID)
// Simulate update where UUID might be accidentally cleared
hostWithEmptyUUID := createdHost
hostWithEmptyUUID.Host.UUID = ""
hostWithEmptyUUID.Host.Hostname = "regression-hostname"
// This should still work but UUID should be empty
err = ds.UpdateAndroidHost(testCtx(), hostWithEmptyUUID, false, false)
require.NoError(t, err)
// UUID is now empty
resultAfterBug, err := ds.AndroidHostLite(testCtx(), regressionESID)
require.NoError(t, err)
assert.Equal(t, "", resultAfterBug.Host.UUID, "UUID should be empty after update without UUID set (documents the bug)")
// Update with UUID properly set
hostWithUUID := resultAfterBug
hostWithUUID.Host.UUID = regressionESID
hostWithUUID.Host.Hostname = "fixed-hostname"
err = ds.UpdateAndroidHost(testCtx(), hostWithUUID, false, false)
require.NoError(t, err)
// UUID is restored
resultAfterFix, err := ds.AndroidHostLite(testCtx(), regressionESID)
require.NoError(t, err)
assert.Equal(t, regressionESID, resultAfterFix.Host.UUID, "UUID should be restored after fix")
})
t.Run("COBO re-enroll restores installed_from_dep", func(t *testing.T) {
ctx := testCtx()
cobo, err := ds.NewAndroidHost(ctx, createAndroidHost("cobo-reenroll-installed-from-dep"), true /*companyOwned*/)
require.NoError(t, err)
hostMDM, err := ds.GetHostMDM(ctx, cobo.Host.ID)
require.NoError(t, err)
require.True(t, hostMDM.InstalledFromDep, "fresh COBO enrollment must set installed_from_dep")
// Simulate the unenroll cleanup that clears installed_from_dep so enrollment_status drops to "Off".
didUnenroll, err := ds.SetAndroidHostUnenrolled(ctx, cobo.Host.ID)
require.NoError(t, err)
require.True(t, didUnenroll)
hostMDM, err = ds.GetHostMDM(ctx, cobo.Host.ID)
require.NoError(t, err)
require.False(t, hostMDM.InstalledFromDep, "unenroll must clear installed_from_dep")
// Re-enroll via the same upsert path that fires from updateHost(fromEnroll=true).
cobo.Host.UUID = "cobo-reenroll-installed-from-dep"
require.NoError(t, ds.UpdateAndroidHost(ctx, cobo, true /*fromEnroll*/, true /*companyOwned*/))
hostMDM, err = ds.GetHostMDM(ctx, cobo.Host.ID)
require.NoError(t, err)
require.True(t, hostMDM.Enrolled)
require.True(t, hostMDM.InstalledFromDep, "re-enroll must refresh installed_from_dep so COBO lands at 'On (automatic)'")
require.False(t, hostMDM.IsPersonalEnrollment)
})
t.Run("does not overwrite admin team transfer", func(t *testing.T) {
test.AddBuiltinLabels(t, ds)
h := createAndroidHost("team-race-" + fmt.Sprintf("%d", time.Now().UnixNano()))
created, err := ds.NewAndroidHost(testCtx(), h, false)
require.NoError(t, err)
team, err := ds.NewTeam(testCtx(), &fleet.Team{Name: fmt.Sprintf("android-team-%d", time.Now().UnixNano())})
require.NoError(t, err)
// Admin transfers host to the team.
require.NoError(t, ds.AddHostsToTeam(testCtx(),
fleet.NewAddHostsToTeamParams(&team.ID, []uint{created.Host.ID})))
// The in-memory host still has TeamID=nil (loaded before the transfer).
created.TeamID = nil
require.NoError(t, ds.UpdateAndroidHost(testCtx(), created, false, false))
reloaded, err := ds.AndroidHostLite(testCtx(), created.Host.UUID)
require.NoError(t, err)
require.NotNil(t, reloaded.TeamID, "UpdateAndroidHost must not clobber team_id set by a concurrent transfer")
assert.Equal(t, team.ID, *reloaded.TeamID)
// Reverse: host is transferred off team while stale struct still carries old team_id.
require.NoError(t, ds.AddHostsToTeam(testCtx(),
fleet.NewAddHostsToTeamParams(nil, []uint{created.Host.ID})))
created.TeamID = &team.ID
require.NoError(t, ds.UpdateAndroidHost(testCtx(), created, false, false))
reloaded, err = ds.AndroidHostLite(testCtx(), created.Host.UUID)
require.NoError(t, err)
assert.Nil(t, reloaded.TeamID, "UpdateAndroidHost must not resurrect a team_id that was cleared by a concurrent transfer")
})
}
func testAndroidMDMStats(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
const appleMDMURL = "/mdm/apple/mdm"
const serverURL = "http://androidmdm.example.com"
appCfg, err := ds.AppConfig(testCtx())
require.NoError(t, err)
appCfg.ServerSettings.ServerURL = serverURL
err = ds.SaveAppConfig(testCtx(), appCfg)
require.NoError(t, err)
// create a few android hosts
hosts := make([]*fleet.Host, 3)
var androidHost0 *fleet.AndroidHost
for i := range hosts {
host := createAndroidHost(uuid.NewString())
result, err := ds.NewAndroidHost(testCtx(), host, false)
require.NoError(t, err)
hosts[i] = result.Host
if androidHost0 == nil {
androidHost0 = host
}
}
// create a non-android host
macHost, err := ds.NewHost(testCtx(), &fleet.Host{
Hostname: "test-host1-name",
OsqueryHostID: ptr.String("1337"),
NodeKey: ptr.String("1337"),
UUID: "test-uuid-1",
Platform: "darwin",
HardwareSerial: uuid.NewString(),
})
require.NoError(t, err)
nanoEnroll(t, ds, macHost, false)
err = ds.MDMAppleUpsertHost(testCtx(), macHost, false)
require.NoError(t, err)
// create a non-mdm host
linuxHost, err := ds.NewHost(testCtx(), &fleet.Host{
Hostname: "test-host2-name",
OsqueryHostID: ptr.String("1338"),
NodeKey: ptr.String("1338"),
UUID: "test-uuid-2",
Platform: "linux",
HardwareSerial: uuid.NewString(),
})
require.NoError(t, err)
require.NotNil(t, linuxHost)
// stats not computed yet
statusStats, _, err := ds.AggregatedMDMStatus(testCtx(), nil, "")
require.NoError(t, err)
solutionsStats, _, err := ds.AggregatedMDMSolutions(testCtx(), nil, "")
require.NoError(t, err)
require.Equal(t, fleet.AggregatedMDMStatus{}, statusStats)
require.Equal(t, []fleet.AggregatedMDMSolutions(nil), solutionsStats)
// compute stats
err = ds.GenerateAggregatedMunkiAndMDM(testCtx())
require.NoError(t, err)
statusStats, _, err = ds.AggregatedMDMStatus(testCtx(), nil, "")
require.NoError(t, err)
solutionsStats, _, err = ds.AggregatedMDMSolutions(testCtx(), nil, "")
require.NoError(t, err)
// 3 Android hosts with UUID are counted as personal enrollment, 1 macOS host as manual
require.Equal(t, fleet.AggregatedMDMStatus{HostsCount: 4, EnrolledManualHostsCount: 1, EnrolledPersonalHostsCount: 3}, statusStats)
require.Len(t, solutionsStats, 2)
// both solutions are Fleet
require.Equal(t, fleet.WellKnownMDMFleet, solutionsStats[0].Name)
require.Equal(t, fleet.WellKnownMDMFleet, solutionsStats[1].Name)
// one is the Android server URL, one is the Apple URL
for _, sol := range solutionsStats {
switch sol.ServerURL {
case serverURL:
require.Equal(t, 3, sol.HostsCount)
case serverURL + appleMDMURL:
require.Equal(t, 1, sol.HostsCount)
default:
require.Failf(t, "unexpected server URL: %v", sol.ServerURL)
}
}
// filter on android
statusStats, _, err = ds.AggregatedMDMStatus(testCtx(), nil, "android")
require.NoError(t, err)
solutionsStats, _, err = ds.AggregatedMDMSolutions(testCtx(), nil, "android")
require.NoError(t, err)
// All 3 Android hosts with UUID are counted as personal enrollment
require.Equal(t, fleet.AggregatedMDMStatus{HostsCount: 3, EnrolledPersonalHostsCount: 3}, statusStats)
require.Len(t, solutionsStats, 1)
require.Equal(t, 3, solutionsStats[0].HostsCount)
require.Equal(t, serverURL, solutionsStats[0].ServerURL)
// turn MDM off for android
err = ds.DeleteAllEnterprises(testCtx())
require.NoError(t, err)
err = ds.BulkSetAndroidHostsUnenrolled(testCtx())
require.NoError(t, err)
// compute stats
err = ds.GenerateAggregatedMunkiAndMDM(testCtx())
require.NoError(t, err)
statusStats, _, err = ds.AggregatedMDMStatus(testCtx(), nil, "")
require.NoError(t, err)
solutionsStats, _, err = ds.AggregatedMDMSolutions(testCtx(), nil, "")
require.NoError(t, err)
require.Equal(t, fleet.AggregatedMDMStatus{HostsCount: 4, EnrolledManualHostsCount: 1, UnenrolledHostsCount: 3}, statusStats)
require.Len(t, solutionsStats, 1)
require.Equal(t, 1, solutionsStats[0].HostsCount)
require.Equal(t, serverURL+appleMDMURL, solutionsStats[0].ServerURL)
// filter on android
statusStats, _, err = ds.AggregatedMDMStatus(testCtx(), nil, "android")
require.NoError(t, err)
solutionsStats, _, err = ds.AggregatedMDMSolutions(testCtx(), nil, "android")
require.NoError(t, err)
require.Equal(t, fleet.AggregatedMDMStatus{HostsCount: 3, UnenrolledHostsCount: 3}, statusStats)
require.Len(t, solutionsStats, 0)
// simulate an android host that re-enrolls
err = ds.UpdateAndroidHost(testCtx(), androidHost0, true, false)
require.NoError(t, err)
// compute stats
err = ds.GenerateAggregatedMunkiAndMDM(testCtx())
require.NoError(t, err)
// filter on android
statusStats, _, err = ds.AggregatedMDMStatus(testCtx(), nil, "android")
require.NoError(t, err)
solutionsStats, _, err = ds.AggregatedMDMSolutions(testCtx(), nil, "android")
require.NoError(t, err)
// After re-enrollment, 1 Android host with UUID is counted as personal enrollment
require.Equal(t, fleet.AggregatedMDMStatus{HostsCount: 3, UnenrolledHostsCount: 2, EnrolledPersonalHostsCount: 1}, statusStats)
require.Len(t, solutionsStats, 1)
require.Equal(t, 1, solutionsStats[0].HostsCount)
require.Equal(t, serverURL, solutionsStats[0].ServerURL)
}
// Test that BatchSetMDMProfiles properly inserts Android profiles when the
// incoming profiles have empty ProfileUUIDs and still applies label
// associations (i.e. matching by team_id + name works).
func testBatchSetMDMAndroidProfiles_Associations(t *testing.T, ds *Datastore) {
// Ensure builtin labels exist
test.AddBuiltinLabels(t, ds)
// Prepare an incoming Android profile without ProfileUUID and with a label
teamID := uint(0)
profName := "test-android-profile"
incoming := &fleet.MDMAndroidConfigProfile{
ProfileUUID: "", // intentionally empty to exercise DB-generated uuid flow
Name: profName,
RawJSON: json.RawMessage(`{"k":"v"}`),
TeamID: nil,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{
LabelName: fleet.BuiltinLabelNameAndroid,
}},
}
// Look up the builtin Android label id and set it on the incoming profile
var lblID uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &lblID, `SELECT id FROM labels WHERE name = ?`, fleet.BuiltinLabelNameAndroid)
})
// assign the id so the label association insertion uses a valid FK
if len(incoming.LabelsIncludeAll) > 0 {
incoming.LabelsIncludeAll[0].LabelID = lblID
}
// Call BatchSetMDMProfiles with only android profiles populated
_, err := ds.BatchSetMDMProfiles(testCtx(), &teamID, nil, nil, nil, []*fleet.MDMAndroidConfigProfile{incoming}, nil)
require.NoError(t, err)
// Verify the profile exists in the DB
var dbCount int
err = sqlx.GetContext(testCtx(), ds.writer(testCtx()), &dbCount, `SELECT COUNT(1) FROM mdm_android_configuration_profiles WHERE name = ? AND team_id = ?`, profName, teamID)
require.NoError(t, err)
assert.Equal(t, 1, dbCount)
// Verify that a label association was created for the profile by querying
// mdm_configuration_profile_labels joined to mdm_android_configuration_profiles
var assocCount int
query := `SELECT COUNT(1) FROM mdm_configuration_profile_labels l JOIN mdm_android_configuration_profiles p ON l.android_profile_uuid = p.profile_uuid WHERE p.name = ? AND p.team_id = ? AND l.label_name = ?`
err = sqlx.GetContext(testCtx(), ds.writer(testCtx()), &assocCount, query, profName, teamID, fleet.BuiltinLabelNameAndroid)
require.NoError(t, err)
assert.Equal(t, 1, assocCount, "expected a label association for the inserted android profile")
}
func testAndroidHostStorageData(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
// Android host with storage data
const enterpriseSpecificID = "storage_test_enterprise"
host := &fleet.AndroidHost{
Host: &fleet.Host{
Hostname: "android-storage-test",
ComputerName: "Android Storage Test Device",
Platform: "android",
OSVersion: "Android 14",
Build: "UPB4.230623.005",
Memory: 8192, // 8GB RAM
TeamID: nil,
HardwareSerial: "STORAGE-TEST-SERIAL",
CPUType: "arm64-v8a",
HardwareModel: "Google Pixel 8 Pro",
HardwareVendor: "Google",
GigsTotalDiskSpace: 128.0, // 64GB system + 64GB external
GigsDiskSpaceAvailable: 35.0, // 10GB + 25GB available
PercentDiskSpaceAvailable: 27.34, // 35/128 * 100
},
Device: &android.Device{
DeviceID: "storage-test-device-id",
EnterpriseSpecificID: ptr.String(enterpriseSpecificID),
AppliedPolicyID: ptr.String("1"),
LastPolicySyncTime: ptr.Time(time.Now().UTC().Truncate(time.Millisecond)),
},
}
host.SetNodeKey(enterpriseSpecificID)
// NewAndroidHost with storage data
result, err := ds.NewAndroidHost(testCtx(), host, false)
require.NoError(t, err)
require.NotZero(t, result.Host.ID)
// storage data was saved correctly
assert.Equal(t, 128.0, result.Host.GigsTotalDiskSpace, "Total disk space should be saved")
assert.Equal(t, 35.0, result.Host.GigsDiskSpaceAvailable, "Available disk space should be saved")
assert.Equal(t, 27.34, result.Host.PercentDiskSpaceAvailable, "Disk space percentage should be saved")
// AndroidHostLite provides lightweight Android data (no storage data)
resultLite, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
require.NoError(t, err)
assert.Equal(t, result.Host.ID, resultLite.Host.ID)
// UpdateAndroidHost preserves storage data
updatedHost := result
updatedHost.Host.Hostname = "updated-hostname"
updatedHost.Host.GigsTotalDiskSpace = 256.0 // Updated: 128GB system + 128GB external
updatedHost.Host.GigsDiskSpaceAvailable = 64.0 // Updated: 20GB + 44GB available
updatedHost.Host.PercentDiskSpaceAvailable = 25.0 // Updated: 64/256 * 100
err = ds.UpdateAndroidHost(testCtx(), updatedHost, false, false)
require.NoError(t, err)
// verify updated host data via host query (includes storage from host_disks)
finalResult, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
require.NoError(t, err)
// get host data to check storage updates
updatedFullHost, err := ds.Host(testCtx(), finalResult.Host.ID)
require.NoError(t, err)
assert.Equal(t, "updated-hostname", updatedFullHost.Hostname, "Hostname should be updated")
assert.Equal(t, 256.0, updatedFullHost.GigsTotalDiskSpace, "Updated total disk space should be saved in host_disks")
assert.Equal(t, 64.0, updatedFullHost.GigsDiskSpaceAvailable, "Updated available disk space should be saved in host_disks")
assert.Equal(t, 25.0, updatedFullHost.PercentDiskSpaceAvailable, "Updated disk space percentage should be saved in host_disks")
}
func testNewMDMAndroidConfigProfile(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := testCtx()
// create some labels to test
lblExcl, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-label-1", Query: "select 1"})
require.NoError(t, err)
lblInclAny, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-label-2", Query: "select 2"})
require.NoError(t, err)
lblInclAll, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclall-label-3", Query: "select 3"})
require.NoError(t, err)
// New Android MDM config profile
profile := fleet.MDMAndroidConfigProfile{
Name: "testAndroid",
TeamID: nil,
RawJSON: []byte(`{"hello": "world"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{
LabelID: lblInclAll.ID,
LabelName: lblInclAll.Name,
RequireAll: true,
}},
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{{
LabelID: lblInclAny.ID,
LabelName: lblInclAny.Name,
RequireAll: false,
}},
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{{
LabelID: lblExcl.ID,
LabelName: lblExcl.Name,
RequireAll: false,
Exclude: true,
}},
}
// Create the profile
result, err := ds.NewMDMAndroidConfigProfile(ctx, profile, nil)
require.NoError(t, err)
assert.NotEmpty(t, result.ProfileUUID)
// Create another profile just to have multiple entries
profile2 := fleet.MDMAndroidConfigProfile{
Name: "testAndroid2",
TeamID: nil,
RawJSON: []byte(`{"hello2": "world2"}`),
}
result2, err := ds.NewMDMAndroidConfigProfile(ctx, profile2, nil)
require.NoError(t, err)
assert.NotEmpty(t, result2.ProfileUUID)
returnedProfile, err := ds.GetMDMAndroidConfigProfile(ctx, result.ProfileUUID)
require.NoError(t, err)
require.NotNil(t, returnedProfile)
// Verify the profile was created correctly
assert.Equal(t, profile.RawJSON, returnedProfile.RawJSON)
assert.Equal(t, profile.Name, returnedProfile.Name)
require.NotNil(t, returnedProfile.TeamID)
assert.Equal(t, uint(0), *returnedProfile.TeamID)
require.ElementsMatch(t, profile.LabelsIncludeAll, returnedProfile.LabelsIncludeAll)
require.ElementsMatch(t, profile.LabelsIncludeAny, returnedProfile.LabelsIncludeAny)
require.ElementsMatch(t, profile.LabelsExcludeAny, returnedProfile.LabelsExcludeAny)
// Create a Windows profile with a name, then make sure an error is returned when creating an
// Android profile with that name
windowsProfile := fleet.MDMWindowsConfigProfile{
Name: "testWindowsAndroidConflict",
TeamID: nil,
SyncML: []byte(`hello`),
}
_, err = ds.NewMDMWindowsConfigProfile(ctx, windowsProfile, nil)
require.NoError(t, err)
androidProfile := fleet.MDMAndroidConfigProfile{
Name: "testWindowsAndroidConflict",
TeamID: nil,
RawJSON: []byte(`{"hello3": "world3"}`),
}
_, err = ds.NewMDMAndroidConfigProfile(ctx, androidProfile, nil)
require.ErrorContains(t, err, "already exists")
// Create that same conflicting android profile but on a different team
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test team"})
require.NoError(t, err)
require.NotNil(t, team)
androidProfile.TeamID = ptr.Uint(team.ID)
otherTeamProfile, err := ds.NewMDMAndroidConfigProfile(ctx, androidProfile, nil)
require.NoError(t, err)
// Verify we can GET the newly created profile
otherTeamProfile, err = ds.GetMDMAndroidConfigProfile(ctx, otherTeamProfile.ProfileUUID)
require.NoError(t, err)
require.NotNil(t, otherTeamProfile)
assert.Equal(t, androidProfile.RawJSON, otherTeamProfile.RawJSON)
assert.Equal(t, androidProfile.Name, otherTeamProfile.Name)
require.NotNil(t, otherTeamProfile.TeamID)
assert.Equal(t, *androidProfile.TeamID, *otherTeamProfile.TeamID)
}
func testGetMDMAndroidConfigProfile(t *testing.T, ds *Datastore) {
ctx := testCtx()
profile, err := ds.GetMDMAndroidConfigProfile(ctx, "some-fake-uuid")
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
require.Nil(t, profile)
}
func testDeleteMDMAndroidConfigProfile(t *testing.T, ds *Datastore) {
ctx := testCtx()
err := ds.DeleteMDMAndroidConfigProfile(ctx, "some-fake-uuid")
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
profile1 := &fleet.MDMAndroidConfigProfile{
Name: "testAndroid",
TeamID: nil,
RawJSON: []byte(`{"hello": "world"}`),
}
profile1, err = ds.NewMDMAndroidConfigProfile(ctx, *profile1, nil)
require.NoError(t, err)
require.NotNil(t, profile1)
profile2 := &fleet.MDMAndroidConfigProfile{
Name: "testAndroid2",
TeamID: nil,
RawJSON: []byte(`{"hello": "world"}`),
}
profile2, err = ds.NewMDMAndroidConfigProfile(ctx, *profile2, nil)
require.NoError(t, err)
require.NotNil(t, profile2)
// set a host profile to mimic reconcilation has yet to run
err = ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: "test-host-1",
ProfileUUID: profile1.ProfileUUID,
Status: nil,
OperationType: fleet.MDMOperationTypeInstall,
},
{
HostUUID: "test-host-2",
ProfileUUID: profile2.ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
},
})
require.NoError(t, err)
// Delete the first profile
err = ds.DeleteMDMAndroidConfigProfile(ctx, profile1.ProfileUUID)
require.NoError(t, err)
// Verify the first profile is deleted and respective host profile is cancelled
profile1, err = ds.GetMDMAndroidConfigProfile(ctx, profile1.ProfileUUID)
require.ErrorAs(t, err, &nfe)
require.Nil(t, profile1)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
stmt := `SELECT host_uuid, profile_uuid FROM host_mdm_android_profiles`
var hosts []struct {
HostUUID string `db:"host_uuid"`
ProfileUUID string `db:"profile_uuid"`
}
err := sqlx.SelectContext(ctx, q, &hosts, stmt)
if err != nil {
return err
}
require.NoError(t, err)
require.Len(t, hosts, 1)
require.Equal(t, "test-host-2", hosts[0].HostUUID)
return nil
})
// Verify the second profile is untouched
profile2, err = ds.GetMDMAndroidConfigProfile(ctx, profile2.ProfileUUID)
require.NoError(t, err)
require.NotNil(t, profile2)
require.Equal(t, "testAndroid2", profile2.Name)
}
func testUpdateMDMAndroidConfigProfile(t *testing.T, ds *Datastore) {
ctx := testCtx()
// profile content update happens in place: the ProfileUUID is preserved
// (not a delete+recreate), and the new content is actually persisted --
// confirmed below by re-fetching from the DB, not just trusting the
// value UpdateMDMAndroidConfigProfile returns.
initial, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Update Test Profile",
RawJSON: []byte(`{"original": true}`),
}, nil)
require.NoError(t, err)
newRawJSON := []byte(`{"updated": true}`)
updated, err := ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
RawJSON: newRawJSON,
}, nil)
require.NoError(t, err)
require.Equal(t, initial.ProfileUUID, updated.ProfileUUID)
require.JSONEq(t, string(newRawJSON), string(updated.RawJSON))
// confirms values actually stored in the DB match what was returned from the update call
stored, err := ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.JSONEq(t, string(newRawJSON), string(stored.RawJSON))
// mismatched name is rejected -- Android profiles have no separate
// identifier field, so name is the only identity a profile has. This is
// the only layer this can be tested at: the service layer never exposes
// a way for a client to submit a different name on an edit.
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: "A Different Name",
RawJSON: newRawJSON,
}, nil)
require.ErrorContains(t, err, "must match the existing profile's name")
// updating a nonexistent profile returns a not-found error
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: "g" + uuid.NewString(),
Name: "Does Not Exist",
RawJSON: newRawJSON,
}, nil)
require.True(t, fleet.IsNotFound(err))
// labels replace the previous set entirely rather than merging with it
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-label-1", Query: "select 1"})
require.NoError(t, err)
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-label-2", Query: "select 1"})
require.NoError(t, err)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: label1.Name, LabelID: label1.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.Len(t, stored.LabelsIncludeAll, 1)
require.Equal(t, label1.Name, stored.LabelsIncludeAll[0].LabelName)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: label2.Name, LabelID: label2.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.Len(t, stored.LabelsIncludeAll, 1)
require.Equal(t, label2.Name, stored.LabelsIncludeAll[0].LabelName, "the previous label must be replaced, not merged with")
// labels can be cleared entirely, not just replaced with a different set --
// exercises the profsWithoutLabel branch, a distinct code path from "has
// labels".
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.Empty(t, stored.LabelsIncludeAll)
require.Empty(t, stored.LabelsIncludeAny)
require.Empty(t, stored.LabelsExcludeAny)
// LabelsIncludeAny and LabelsExcludeAny replace the same way LabelsIncludeAll
// does above -- each is a separate label list on the profile.
anyExcludeProfile, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Any Exclude Labels Profile",
RawJSON: []byte(`{"anyExclude": true}`),
}, nil)
require.NoError(t, err)
includeAnyLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-include-any", Query: "select 1"})
require.NoError(t, err)
excludeAnyLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-exclude-any", Query: "select 1"})
require.NoError(t, err)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: anyExcludeProfile.ProfileUUID,
Name: anyExcludeProfile.Name,
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelName: includeAnyLabel.Name, LabelID: includeAnyLabel.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, anyExcludeProfile.ProfileUUID)
require.NoError(t, err)
require.Len(t, stored.LabelsIncludeAny, 1)
require.Equal(t, includeAnyLabel.Name, stored.LabelsIncludeAny[0].LabelName)
require.Empty(t, stored.LabelsExcludeAny)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: anyExcludeProfile.ProfileUUID,
Name: anyExcludeProfile.Name,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelName: excludeAnyLabel.Name, LabelID: excludeAnyLabel.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, anyExcludeProfile.ProfileUUID)
require.NoError(t, err)
require.Empty(t, stored.LabelsIncludeAny, "the previous IncludeAny label must be replaced, not kept alongside ExcludeAny")
require.Len(t, stored.LabelsExcludeAny, 1)
require.Equal(t, excludeAnyLabel.Name, stored.LabelsExcludeAny[0].LabelName)
// content and labels updated together in a single call -- proves the two
// transactional steps (content UPDATE, label rebuild) compose correctly,
// not just each dimension on its own.
combined, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Combined Update Profile",
RawJSON: []byte(`{"combinedOriginal": true}`),
}, nil)
require.NoError(t, err)
combinedLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-combined-label", Query: "select 1"})
require.NoError(t, err)
combinedRawJSON := []byte(`{"combinedUpdated": true}`)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: combined.ProfileUUID,
Name: combined.Name,
RawJSON: combinedRawJSON,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: combinedLabel.Name, LabelID: combinedLabel.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, combined.ProfileUUID)
require.NoError(t, err)
require.JSONEq(t, string(combinedRawJSON), string(stored.RawJSON))
require.Len(t, stored.LabelsIncludeAll, 1)
require.Equal(t, combinedLabel.Name, stored.LabelsIncludeAll[0].LabelName)
// Fleet variables used in the new content are persisted, a labels-only
// edit (no new content) leaves them untouched, and a content edit that
// drops the last variable clears the stale association.
varNamesStmt := `
SELECT fv.name
FROM mdm_configuration_profile_variables mcpv
JOIN fleet_variables fv ON mcpv.fleet_variable_id = fv.id
WHERE mcpv.android_profile_uuid = ?
ORDER BY fv.name
`
varProfile, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Android Fleet Vars Profile",
RawJSON: []byte(`{"managedConfiguration": {"platform": "$FLEET_VAR_HOST_PLATFORM"}}`),
}, []fleet.FleetVarName{fleet.FleetVarHostPlatform})
require.NoError(t, err)
varLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-labels-only-vars-label", Query: "select 1"})
require.NoError(t, err)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: varProfile.ProfileUUID,
Name: varProfile.Name,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: varLabel.Name, LabelID: varLabel.ID},
},
}, nil)
require.NoError(t, err)
var varNames []string
err = ds.writer(ctx).SelectContext(ctx, &varNames, varNamesStmt, varProfile.ProfileUUID)
require.NoError(t, err)
require.Equal(t, []string{"FLEET_VAR_" + string(fleet.FleetVarHostPlatform)}, varNames,
"a labels-only edit must preserve the profile's variable associations")
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: varProfile.ProfileUUID,
Name: varProfile.Name,
RawJSON: []byte(`{"managedConfiguration": {"platform": "static"}}`),
}, nil)
require.NoError(t, err)
err = ds.writer(ctx).SelectContext(ctx, &varNames, varNamesStmt, varProfile.ProfileUUID)
require.NoError(t, err)
require.Empty(t, varNames, "a content edit that drops the last Fleet variable must clear the stale association")
// uploaded_at is preserved on a no-op edit (identical content) and bumped
// on a real content change, matching the batch upsert's convention
uploadedAtRawJSON := []byte(`{"uploadedAt": true}`)
uploadedAtProfile, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Uploaded At Profile",
RawJSON: uploadedAtRawJSON,
}, nil)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE mdm_android_configuration_profiles SET uploaded_at = '2020-01-01 00:00:00' WHERE profile_uuid = ?`, uploadedAtProfile.ProfileUUID)
return err
})
noOp, err := ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: uploadedAtProfile.ProfileUUID,
Name: uploadedAtProfile.Name,
RawJSON: uploadedAtRawJSON,
}, nil)
require.NoError(t, err)
require.Equal(t, 2020, noOp.UploadedAt.Year(), "a no-op edit must not bump uploaded_at")
contentChangedProf, err := ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: uploadedAtProfile.ProfileUUID,
Name: uploadedAtProfile.Name,
RawJSON: []byte(`{"uploadedAt": false}`),
}, nil)
require.NoError(t, err)
require.Greater(t, contentChangedProf.UploadedAt.Year(), 2020, "a content change must bump uploaded_at")
}
func testMDMAndroidProfilesSummary(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := context.Background()
checkMDMProfilesSummary := func(t *testing.T, teamID *uint, expected fleet.MDMProfilesSummary) {
ps, err := ds.GetMDMAndroidProfilesSummary(ctx, teamID)
require.NoError(t, err)
require.NotNil(t, ps)
require.Equal(t, expected, *ps)
}
checkListHostsFilterOSSettings := func(t *testing.T, teamID *uint, status fleet.OSSettingsStatus, expectedIDs []uint) {
gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status})
require.NoError(t, err)
if len(expectedIDs) != len(gotHosts) {
gotIDs := make([]uint, len(gotHosts))
for i, h := range gotHosts {
gotIDs[i] = h.ID
}
require.Len(t, gotHosts, len(expectedIDs), fmt.Sprintf("status: %s expected: %v got: %v", status, expectedIDs, gotIDs))
}
for _, h := range gotHosts {
require.Contains(t, expectedIDs, h.ID)
}
count, err := ds.CountHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status})
require.NoError(t, err)
require.Equal(t, len(expectedIDs), count, "status: %s", status)
}
type hostIDsByProfileStatus map[fleet.MDMDeliveryStatus][]uint
checkExpected := func(t *testing.T, teamID *uint, ep hostIDsByProfileStatus) {
checkMDMProfilesSummary(t, teamID, fleet.MDMProfilesSummary{
Pending: uint(len(ep[fleet.MDMDeliveryPending])),
Failed: uint(len(ep[fleet.MDMDeliveryFailed])),
Verifying: uint(len(ep[fleet.MDMDeliveryVerifying])),
Verified: uint(len(ep[fleet.MDMDeliveryVerified])),
})
checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerified, ep[fleet.MDMDeliveryVerified])
checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerifying, ep[fleet.MDMDeliveryVerifying])
checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsFailed, ep[fleet.MDMDeliveryFailed])
checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsPending, ep[fleet.MDMDeliveryPending])
}
upsertHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, status *fleet.MDMDeliveryStatus) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO host_mdm_android_profiles (host_uuid, profile_uuid, status, operation_type) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`
_, err := q.ExecContext(ctx, stmt, hostUUID, profUUID, status, fleet.MDMOperationTypeInstall, status)
return err
})
}
cleanupTables := func(t *testing.T) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_android_profiles`)
return err
})
}
// Create some hosts
var hosts []*fleet.Host
for i := 0; i < 5; i++ {
androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", i))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
require.NotNil(t, newHost)
hosts = append(hosts, newHost.Host)
}
t.Run("profiles summary empty when there are no hosts with statuses", func(t *testing.T) {
expected := hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{},
fleet.MDMDeliveryVerifying: []uint{},
fleet.MDMDeliveryVerified: []uint{},
fleet.MDMDeliveryFailed: []uint{},
}
checkExpected(t, nil, expected)
})
t.Run("profiles summary accounts for host profiles with mixed statuses", func(t *testing.T) {
for i := 0; i < 5; i++ {
// upsert five profiles for hosts[0] with nil statuses
upsertHostProfileStatus(t, hosts[0].UUID, fmt.Sprintf("some-android-profile-%d", i), nil)
// upsert five profiles for hosts[1] with pending statuses
upsertHostProfileStatus(t, hosts[1].UUID, fmt.Sprintf("some-android-profile-%d", i), &fleet.MDMDeliveryPending)
// upsert five profiles for hosts[2] with verifying statuses
upsertHostProfileStatus(t, hosts[2].UUID, fmt.Sprintf("some-android-profile-%d", i), &fleet.MDMDeliveryVerifying)
// upsert five profiles for hosts[3] with verified statuses
upsertHostProfileStatus(t, hosts[3].UUID, fmt.Sprintf("some-android-profile-%d", i), &fleet.MDMDeliveryVerified)
// upsert five profiles for hosts[4] with failed statuses
upsertHostProfileStatus(t, hosts[4].UUID, fmt.Sprintf("some-android-profile-%d", i), &fleet.MDMDeliveryFailed)
}
expected := hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[1].ID},
fleet.MDMDeliveryVerifying: []uint{hosts[2].ID},
fleet.MDMDeliveryVerified: []uint{hosts[3].ID},
fleet.MDMDeliveryFailed: []uint{hosts[4].ID},
}
checkExpected(t, nil, expected)
// add some other android hosts that won't be be assigned any profiles
for i := 0; i < 5; i++ {
androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-other-%d", i))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
require.NotNil(t, newHost)
}
checkExpected(t, nil, expected)
// upsert some-profile-0 to failed status for hosts[0:4]
for i := 0; i < 5; i++ {
upsertHostProfileStatus(t, hosts[i].UUID, "some-android-profile-0", &fleet.MDMDeliveryFailed)
}
expected = hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{},
fleet.MDMDeliveryVerifying: []uint{},
fleet.MDMDeliveryVerified: []uint{},
fleet.MDMDeliveryFailed: []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID},
}
checkExpected(t, nil, expected)
// upsert some-profile-0 to pending status for hosts[0:4]
for i := 0; i < 5; i++ {
upsertHostProfileStatus(t, hosts[i].UUID, "some-android-profile-0", &fleet.MDMDeliveryPending)
}
expected = hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID},
fleet.MDMDeliveryVerifying: []uint{},
fleet.MDMDeliveryVerified: []uint{},
fleet.MDMDeliveryFailed: []uint{hosts[4].ID},
}
checkExpected(t, nil, expected)
// upsert some-profile-0 to verifying status for hosts[0:4]
for i := 0; i < 5; i++ {
upsertHostProfileStatus(t, hosts[i].UUID, "some-android-profile-0", &fleet.MDMDeliveryVerifying)
}
expected = hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[1].ID},
fleet.MDMDeliveryVerifying: []uint{hosts[2].ID, hosts[3].ID},
fleet.MDMDeliveryVerified: []uint{},
fleet.MDMDeliveryFailed: []uint{hosts[4].ID},
}
checkExpected(t, nil, expected)
// upsert some-profile-0 to verified status for hosts[0:4]
for i := 0; i < 5; i++ {
upsertHostProfileStatus(t, hosts[i].UUID, "some-android-profile-0", &fleet.MDMDeliveryVerified)
}
expected = hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[1].ID},
fleet.MDMDeliveryVerifying: []uint{hosts[2].ID},
fleet.MDMDeliveryVerified: []uint{hosts[3].ID},
fleet.MDMDeliveryFailed: []uint{hosts[4].ID},
}
checkExpected(t, nil, expected)
// create a new team
t1, err := ds.NewTeam(ctx, &fleet.Team{Name: uuid.NewString()})
require.NoError(t, err)
require.NotNil(t, t1)
expected = hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{},
fleet.MDMDeliveryVerifying: []uint{},
fleet.MDMDeliveryVerified: []uint{},
fleet.MDMDeliveryFailed: []uint{},
}
checkExpected(t, &t1.ID, expected)
// transfer hosts[1:2] to the team
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&t1.ID, []uint{hosts[1].ID, hosts[2].ID})))
// hosts[1:2] now counted for the team, hosts[2] is counted as verifying again because
// disk encryption is not enabled for the team
expectedTeam1 := hostIDsByProfileStatus{
fleet.MDMDeliveryPending: []uint{hosts[1].ID},
fleet.MDMDeliveryVerifying: []uint{hosts[2].ID},
}
checkExpected(t, &t1.ID, expectedTeam1)
// set MDM to off for hosts[0]
require.NoError(t, ds.SetOrUpdateMDMData(ctx, hosts[0].ID, false, false, "", false, "", "", false))
// hosts[0] is no longer counted
expected = hostIDsByProfileStatus{
fleet.MDMDeliveryVerified: []uint{hosts[3].ID},
fleet.MDMDeliveryFailed: []uint{hosts[4].ID},
}
checkExpected(t, nil, expected)
cleanupTables(t)
})
}
func testGetHostMDMAndroidProfiles(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Create a host
host := createAndroidHost("host-mdm-profiles-test")
newHost, err := ds.NewAndroidHost(ctx, host, false)
require.NoError(t, err)
require.NotNil(t, newHost)
// No profiles initially
profiles, err := ds.GetHostMDMAndroidProfiles(ctx, newHost.UUID)
require.NoError(t, err)
require.Empty(t, profiles)
// Create some profiles
profile1 := androidProfileForTest("profile1")
profile1, err = ds.NewMDMAndroidConfigProfile(ctx, *profile1, nil)
require.NoError(t, err)
require.NotNil(t, profile1)
profile2 := androidProfileForTest("profile2")
profile2, err = ds.NewMDMAndroidConfigProfile(ctx, *profile2, nil)
require.NoError(t, err)
require.NotNil(t, profile2)
profile3 := androidProfileForTest("profile3")
profile3, err = ds.NewMDMAndroidConfigProfile(ctx, *profile3, nil)
require.NoError(t, err)
require.NotNil(t, profile3)
// Assign profiles to host with different statuses
upsertAndroidHostProfileStatus(t, ds, newHost.UUID, profile1.ProfileUUID, &fleet.MDMDeliveryVerified)
upsertAndroidHostProfileStatus(t, ds, newHost.UUID, profile2.ProfileUUID, &fleet.MDMDeliveryPending)
upsertAndroidHostProfileStatus(t, ds, newHost.UUID, profile3.ProfileUUID, nil)
// Retrieve host profiles
profiles, err = ds.GetHostMDMAndroidProfiles(ctx, newHost.UUID)
require.NoError(t, err)
require.Len(t, profiles, 3)
byProfileUUID := make(map[string]fleet.HostMDMAndroidProfile)
for _, p := range profiles {
require.NotNil(t, p.Status)
byProfileUUID[p.ProfileUUID] = p
}
require.Len(t, byProfileUUID, 3)
require.Equal(t, fleet.MDMDeliveryVerified, *byProfileUUID[profile1.ProfileUUID].Status)
require.Equal(t, fleet.MDMDeliveryPending, *byProfileUUID[profile2.ProfileUUID].Status)
require.Equal(t, fleet.MDMDeliveryPending, *byProfileUUID[profile3.ProfileUUID].Status)
// Change status of two profiles
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
// delivery failed
_, err := q.ExecContext(ctx, `UPDATE host_mdm_android_profiles SET status = ? WHERE host_uuid = ? AND profile_uuid = ?`,
fleet.MDMDeliveryFailed, newHost.UUID, profile2.ProfileUUID)
require.NoError(t, err)
// removal verifying
_, err = q.ExecContext(ctx, `UPDATE host_mdm_android_profiles SET operation_type = ?, status = ? WHERE host_uuid = ? AND profile_uuid = ?`,
fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying, newHost.UUID, profile3.ProfileUUID)
return err
})
// Retrieve host profiles
profiles, err = ds.GetHostMDMAndroidProfiles(ctx, newHost.UUID)
require.NoError(t, err)
require.Len(t, profiles, 2) // verifying removal profile not returned
byProfileUUID = make(map[string]fleet.HostMDMAndroidProfile)
for _, p := range profiles {
require.NotNil(t, p.Status)
byProfileUUID[p.ProfileUUID] = p
}
require.Len(t, byProfileUUID, 2)
require.Equal(t, fleet.MDMDeliveryVerified, *byProfileUUID[profile1.ProfileUUID].Status)
require.Equal(t, fleet.MDMDeliveryFailed, *byProfileUUID[profile2.ProfileUUID].Status)
// Non-existent host returns empty slice
profiles, err = ds.GetHostMDMAndroidProfiles(ctx, "non-existent-uuid")
require.NoError(t, err)
require.Empty(t, profiles)
}
func androidProfileForTest(name string, labels ...*fleet.Label) *fleet.MDMAndroidConfigProfile {
payload := `{
"maximumTimeToLock": "1234"
}`
profile := &fleet.MDMAndroidConfigProfile{
RawJSON: []byte(payload),
Name: name,
}
for _, l := range labels {
switch {
case strings.HasPrefix(l.Name, "exclude-"):
profile.LabelsExcludeAny = append(profile.LabelsExcludeAny, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
case strings.HasPrefix(l.Name, "inclany-"):
profile.LabelsIncludeAny = append(profile.LabelsIncludeAny, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
default:
profile.LabelsIncludeAll = append(profile.LabelsIncludeAll, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
}
}
return profile
}
func getAndroidProfileChecksum(t *testing.T, ds *Datastore, profileUUID string) []byte {
var checksum []byte
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &checksum,
`SELECT checksum FROM mdm_android_configuration_profiles WHERE profile_uuid = ?`, profileUUID)
})
return checksum
}
func upsertAndroidHostProfileStatus(t *testing.T, ds *Datastore, hostUUID string, profUUID string, status *fleet.MDMDeliveryStatus) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO host_mdm_android_profiles (host_uuid, profile_uuid, status, operation_type) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`
_, err := q.ExecContext(context.Background(), stmt, hostUUID, profUUID, status, fleet.MDMOperationTypeInstall, status)
return err
})
}
func expectAndroidProfiles(
t *testing.T,
ds *Datastore,
tmID *uint,
want []*fleet.MDMAndroidConfigProfile,
) {
if tmID == nil {
tmID = ptr.Uint(0)
}
ctx := t.Context()
var gotUUIDs []string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &gotUUIDs,
`SELECT profile_uuid FROM mdm_android_configuration_profiles WHERE team_id = ?`,
tmID)
})
// load each profile, this will also load its labels
var got []*fleet.MDMAndroidConfigProfile
for _, profileUUID := range gotUUIDs {
profile, err := ds.GetMDMAndroidConfigProfile(ctx, profileUUID)
require.NoError(t, err)
got = append(got, profile)
}
// create map of expected uuids keyed by name
wantMap := make(map[string]*fleet.MDMAndroidConfigProfile, len(want))
for _, cp := range want {
wantMap[cp.Name] = cp
}
JSONRemarshal := func(bytes []byte) ([]byte, error) {
var ifce interface{}
err := json.Unmarshal(bytes, &ifce)
if err != nil {
return nil, err
}
return json.Marshal(ifce)
}
// compare only the fields we care about, and build the resulting map of
// profile identifier as key to profile UUID as value
for _, gotA := range got {
wantA := wantMap[gotA.Name]
if gotA.TeamID != nil && *gotA.TeamID == 0 {
gotA.TeamID = nil
}
// ProfileUUID is non-empty and starts with "g", but otherwise we don't
// care about it for test assertions.
require.NotEmpty(t, gotA.ProfileUUID)
require.True(t, strings.HasPrefix(gotA.ProfileUUID, fleet.MDMAndroidProfileUUIDPrefix))
gotA.ProfileUUID = ""
gotA.CreatedAt = time.Time{}
gotA.AutoIncrement = 0
gotBytes, err := JSONRemarshal(gotA.RawJSON)
require.NoError(t, err)
gotA.RawJSON = gotBytes
// if an expected uploaded_at timestamp is provided for this profile, keep
// its value, otherwise clear it as we don't care about asserting its
// value.
if wantA.UploadedAt.IsZero() {
gotA.UploadedAt = time.Time{}
}
}
require.ElementsMatch(t, want, got)
}
func testListMDMAndroidProfilesToSend(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
// Create some hosts
hosts := make([]*fleet.Host, 2)
for i := range hosts {
androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", i))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
hosts[i] = newHost.Host
}
// without any profile, should return empty
profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, profs)
require.Empty(t, toRemoveProfs)
// create a couple profiles for no team, and one for a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team"})
require.NoError(t, err)
p1, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-1"), nil)
require.NoError(t, err)
p2, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-2"), nil)
require.NoError(t, err)
tmP3 := androidProfileForTest("team-1")
tmP3.TeamID = &tm.ID
p3, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP3, nil)
require.NoError(t, err)
// all profiles use the same raw JSON, so they share the same checksum
profChecksum := getAndroidProfileChecksum(t, ds, p1.ProfileUUID)
// both no-team profiles should be applicable to both hosts
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p2.Name, Checksum: profChecksum},
}, profs)
// transfer host 1 to the team
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hosts[1].ID}))
require.NoError(t, err)
// profiles for host 1 change to p3
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 3)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// test the include all labels condition
lblIncAll1, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclall-1", Query: "select 1"})
require.NoError(t, err)
lblIncAll2, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclall-2", Query: "select 1"})
require.NoError(t, err)
p4, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-4", lblIncAll1, lblIncAll2), nil)
require.NoError(t, err)
// no change, host is not a member of both labels
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 3)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// make host[0] a member of only one of the labels
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblIncAll1, []uint{hosts[0].ID}, fleet.TeamFilter{})
require.NoError(t, err)
// no change, host is not a member of both labels
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 3)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// make host[0] a member of the other label
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblIncAll2, []uint{hosts[0].ID}, fleet.TeamFilter{})
require.NoError(t, err)
// now p4 is applicable to host 0
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// test the include any labels condition
lblIncAny1, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclany-1", Query: "select 1"})
require.NoError(t, err)
lblIncAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclany-2", Query: "select 1"})
require.NoError(t, err)
p5, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-5", lblIncAny1, lblIncAny2), nil)
require.NoError(t, err)
// no change, host 0 not a member yet
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// make host[0] a member of one of the labels
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblIncAny1, []uint{hosts[0].ID}, fleet.TeamFilter{})
require.NoError(t, err)
// now p5 is applicable to host 0
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 5)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// test the exclude any labels condition
lblExclAny1, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-1", Query: "select 1"})
require.NoError(t, err)
lblExclAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-2", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
p6, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-6", lblExclAny1, lblExclAny2), nil)
require.NoError(t, err)
// no change, label membership was not updated after labels created
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 5)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// update the timestamp of when host label membership was updated
hosts[0].LabelUpdatedAt = time.Now().UTC().Add(time.Second) // just to be extra safe in tests
hosts[0].PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hosts[0])
require.NoError(t, err)
// host 0 is _not_ a member of the excluded labels, so p6 is applicable
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 6)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p6.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p6.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// make host[0] a member of one of the exclude labels
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblExclAny2, []uint{hosts[0].ID}, fleet.TeamFilter{})
require.NoError(t, err)
// p6 is not applicable anymore
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 5)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// add another host in team
androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", 2))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
hosts = append(hosts, newHost.Host)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hosts[2].ID}))
require.NoError(t, err)
// it is not included in noProfHosts as it has p3
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 6)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// simulate that host 2 already has p3 installed
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_android_profiles
(host_uuid, profile_uuid, profile_name, included_in_policy_version, operation_type, status, checksum)
VALUES (?, ?, ?, ?, ?, ?, ?)`, hosts[2].UUID, p3.ProfileUUID, p3.Name, 1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified, profChecksum)
return err
})
// host 2 is not included in the results as it has p3 installed
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 5)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
// delete profile p3
err = ds.DeleteMDMAndroidConfigProfile(ctx, p3.ProfileUUID)
require.NoError(t, err)
// host 2 is now a host with no profile (profile 3 needs to be cleared), host 1 is unlisted as it didn't have p3 installed
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
}, profs)
// Turn off MDM on host 2 - it should no longer have any operations listed
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE host_mdm SET enrolled=0 WHERE host_id=?`, hosts[2].ID)
return err
})
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name, Checksum: profChecksum},
}, profs)
// Turn off MDM on host 0 - no more profiles to send
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE host_mdm SET enrolled=0 WHERE host_id=?`, hosts[0].ID)
return err
})
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, profs)
require.Empty(t, toRemoveProfs)
}
// Specific test for "exclude any" logic which can be tricky because manual
// labels apply immediately whereas dynamic labels only apply after label membership
// has been determined for the host(as signified by the LabelUpdatedAt timestamp).
// Base test covers some of this but it's a good area for extra testing in light of
// https://github.com/fleetdm/fleet/issues/33132
func testListMDMAndroidProfilesToSendWithExcludeAny(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
// Create some hosts
hosts := make([]*fleet.Host, 2)
for i := range hosts {
androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", i))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
hosts[i] = newHost.Host
}
// without any profile, should return empty
profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, profs)
require.Empty(t, toRemoveProfs)
// Create a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team"})
require.NoError(t, err)
// transfer host 1 to the team
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hosts[1].ID}))
require.NoError(t, err)
// test the exclude any labels condition
lblExclAny1, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-1", Query: "select 1"})
require.NoError(t, err)
lblExclAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-2", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
// Dynamic exclude-any label
p1, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-1", lblExclAny1), nil)
require.NoError(t, err)
// Manual exclude-any label only
p2, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-2", lblExclAny2), nil)
require.NoError(t, err)
// Both manual and dynamic label exclusion
p3, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-3", lblExclAny1, lblExclAny2), nil)
require.NoError(t, err)
// all profiles use the same raw JSON, so they share the same checksum
profChecksum := getAndroidProfileChecksum(t, ds, p1.ProfileUUID)
// p2 becomes immediately applicable because it only has a manual label
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 1)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
}, profs)
// update the timestamp of when host label membership was updated
hosts[0].LabelUpdatedAt = time.Now().UTC().Add(time.Second) // just to be extra safe in tests
hosts[0].PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hosts[0])
require.NoError(t, err)
// host 0 dynamic labels now apply, and this host is _not_ a member of the excluded labels, so p1, p2 and p3 are now applicable
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 3)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name, Checksum: profChecksum},
}, profs)
tmP4 := androidProfileForTest("team-4", lblExclAny1)
tmP4.TeamID = &tm.ID
tmP5 := androidProfileForTest("team-5", lblExclAny2)
tmP5.TeamID = &tm.ID
tmP6 := androidProfileForTest("team-6", lblExclAny1, lblExclAny2)
tmP6.TeamID = &tm.ID
// Dynamic exclude-any label
p4, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP4, nil)
require.NoError(t, err)
// Manual exclude-any label only
p5, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP5, nil)
require.NoError(t, err)
// Both manual and dynamic label exclusion
p6, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP6, nil)
require.NoError(t, err)
// p5 becomes immediately applicable to host 1 because it only has a manual label
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name, Checksum: profChecksum},
}, profs)
// Set the hosts label_updated_at causing p4-p6 to become applicable to host 1
hosts[1].LabelUpdatedAt = time.Now().UTC().Add(time.Second) // just to be extra safe in tests
hosts[1].PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hosts[1])
require.NoError(t, err)
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hosts[1].ID})))
hosts[1].TeamID = &tm.ID
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 6)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name, Checksum: profChecksum},
{ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p6.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p6.Name, Checksum: profChecksum},
}, profs)
// Make host 0 a member of labelExclAny2 which excludes everything except p1 for it
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblExclAny2, []uint{hosts[0].ID}, fleet.TeamFilter{})
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 4)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name, Checksum: profChecksum},
{ProfileUUID: p4.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p4.Name, Checksum: profChecksum},
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name, Checksum: profChecksum},
{ProfileUUID: p6.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p6.Name, Checksum: profChecksum},
}, profs)
// Make hosts 0 and 1 members of labelExclAny1 which excludes everything except p5 for host p1. Android doesn't
// currently support dynamic labels but this ensures the datastore processes it right if somehow an Android host
// becomes a member of one
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblExclAny1, []uint{hosts[0].ID, hosts[1].ID}, fleet.TeamFilter{})
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Len(t, profs, 1)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name, Checksum: profChecksum},
}, profs)
}
func testListMDMAndroidProfilesToSendCursor(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
// Create 5 hosts with predictable UUIDs for cursor ordering.
hosts := make([]*fleet.Host, 5)
for i := range hosts {
androidHost := createAndroidHost(fmt.Sprintf("cursor-host-%02d", i))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
hosts[i] = newHost.Host
}
// Sort by UUID so we can predict cursor order.
slices.SortFunc(hosts, func(a, b *fleet.Host) int {
return cmp.Compare(a.UUID, b.UUID)
})
// Add a profile so all 5 hosts have pending work.
_, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("cursor-test-profile"), nil)
require.NoError(t, err)
// No cursor, no limit — returns all 5 hosts.
allProfs, _, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
allHostUUIDs := make(map[string]struct{})
for _, p := range allProfs {
allHostUUIDs[p.HostUUID] = struct{}{}
}
require.Len(t, allHostUUIDs, 5)
// Batch 1: limit 2 hosts, no cursor.
batch1Profs, _, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 2)
require.NoError(t, err)
batch1Hosts := make(map[string]struct{})
for _, p := range batch1Profs {
batch1Hosts[p.HostUUID] = struct{}{}
}
require.Len(t, batch1Hosts, 2, "batch 1 should return exactly 2 hosts")
// Hosts should be the first 2 in sorted order.
sorted1 := slices.Sorted(maps.Keys(batch1Hosts))
require.Equal(t, hosts[0].UUID, sorted1[0])
require.Equal(t, hosts[1].UUID, sorted1[1])
// Batch 2: cursor past the last host of batch 1, limit 2.
cursor := sorted1[len(sorted1)-1]
batch2Profs, _, err := ds.ListMDMAndroidProfilesToSend(ctx, cursor, 2)
require.NoError(t, err)
batch2Hosts := make(map[string]struct{})
for _, p := range batch2Profs {
batch2Hosts[p.HostUUID] = struct{}{}
}
require.Len(t, batch2Hosts, 2, "batch 2 should return exactly 2 hosts")
// No overlap with batch 1, and all hosts should be > cursor.
sorted2 := slices.Sorted(maps.Keys(batch2Hosts))
for _, uuid := range sorted2 {
require.Greater(t, uuid, cursor, "batch 2 hosts must be after cursor")
_, overlap := batch1Hosts[uuid]
require.False(t, overlap, "batch 2 must not overlap with batch 1")
}
// Batch 3: cursor past batch 2, limit 2 — should return the remaining 1 host.
cursor = sorted2[len(sorted2)-1]
batch3Profs, _, err := ds.ListMDMAndroidProfilesToSend(ctx, cursor, 2)
require.NoError(t, err)
batch3Hosts := make(map[string]struct{})
for _, p := range batch3Profs {
batch3Hosts[p.HostUUID] = struct{}{}
}
require.Len(t, batch3Hosts, 1, "batch 3 should return the remaining 1 host")
sorted3 := slices.Sorted(maps.Keys(batch3Hosts))
require.Greater(t, sorted3[0], cursor, "batch 3 host must be after cursor")
// Batch 4: cursor past batch 3 — should return empty (end of pass).
cursor = sorted3[0]
batch4Profs, _, err := ds.ListMDMAndroidProfilesToSend(ctx, cursor, 2)
require.NoError(t, err)
require.Empty(t, batch4Profs, "no more hosts after end of universe")
}
func testListMDMAndroidProfilesToSendWithCombinedLabels(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
host := createAndroidHost("enterprise-id-combined")
newHost, err := ds.NewAndroidHost(ctx, host, false)
require.NoError(t, err)
h := newHost.Host
// advance label_updated_at so dynamic labels are immediately evaluated
h.LabelUpdatedAt = time.Now().UTC().Add(time.Second)
h.PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, h)
require.NoError(t, err)
inclAllLbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "incl-all-1", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
inclAllLbl2, err := ds.NewLabel(ctx, &fleet.Label{Name: "incl-all-2", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
inclAnyLbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclany-any-1", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
exclLbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-1", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
// include-all + exclude-any profile (requires both incl-all-1 and incl-all-2)
pCombinedAll, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("combined-incl-all", inclAllLbl, inclAllLbl2, exclLbl), nil)
require.NoError(t, err)
// include-any + exclude-any profile
pCombinedAny, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("combined-incl-any", inclAnyLbl, exclLbl), nil)
require.NoError(t, err)
profChecksum := getAndroidProfileChecksum(t, ds, pCombinedAll.ProfileUUID)
// host is not a member of any label → neither profile applies
profs, toRemove, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemove)
require.Empty(t, profs)
// host joins include labels but not exclude → both profiles apply
err = ds.AddLabelsToHost(ctx, h.ID, []uint{inclAllLbl.ID, inclAllLbl2.ID, inclAnyLbl.ID})
require.NoError(t, err)
profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemove)
require.Len(t, profs, 2)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pCombinedAll.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAll.Name, Checksum: profChecksum},
{ProfileUUID: pCombinedAny.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAny.Name, Checksum: profChecksum},
}, profs)
// host also joins exclude label → neither profile applies
err = ds.AddLabelsToHost(ctx, h.ID, []uint{exclLbl.ID})
require.NoError(t, err)
profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemove)
require.Empty(t, profs)
// host leaves exclude label → both profiles apply again
err = ds.RemoveLabelsFromHost(ctx, h.ID, []uint{exclLbl.ID})
require.NoError(t, err)
profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemove)
require.Len(t, profs, 2)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pCombinedAll.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAll.Name, Checksum: profChecksum},
{ProfileUUID: pCombinedAny.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAny.Name, Checksum: profChecksum},
}, profs)
// remove host from one include-all label → include-all profile no longer applies, include-any still does
err = ds.RemoveLabelsFromHost(ctx, h.ID, []uint{inclAllLbl2.ID})
require.NoError(t, err)
profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemove)
require.Len(t, profs, 1)
require.Equal(t, pCombinedAny.ProfileUUID, profs[0].ProfileUUID)
}
// insertAndroidHostProfileInstalled simulates a profile fully installed on a host: install
// operation, verified status, and the profile's current checksum so no change is detected.
func insertAndroidHostProfileInstalled(t *testing.T, ds *Datastore, hostUUID string, prof *fleet.MDMAndroidConfigProfile, checksum []byte) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `INSERT INTO host_mdm_android_profiles
(host_uuid, profile_uuid, profile_name, included_in_policy_version, operation_type, status, checksum)
VALUES (?, ?, ?, ?, ?, ?, ?)`, hostUUID, prof.ProfileUUID, prof.Name, 1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified, checksum)
return err
})
}
// A dynamic exclude label whose membership is unknown for a host (label created after the
// host's last label scan) must preserve the host's current profile state: the profile stays
// applicable on hosts that already have it and stays withheld from hosts that don't, until the
// host reports label results (see #47865). Host-vitals exclude labels are server-populated so
// they evaluate immediately, like manual labels.
func testListMDMAndroidProfilesToSendExcludeAnyUnknownLabelPreservation(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
// hostWith will have the profile installed, hostWithout won't. Both keep their initial
// label_updated_at, which predates the labels created below, so dynamic membership is unknown.
newHostWith, err := ds.NewAndroidHost(ctx, createAndroidHost("enterprise-id-0"), false)
require.NoError(t, err)
hostWith := newHostWith.Host
newHostWithout, err := ds.NewAndroidHost(ctx, createAndroidHost("enterprise-id-1"), false)
require.NoError(t, err)
hostWithout := newHostWithout.Host
lblExclDyn, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-dyn", Query: "select 1"})
require.NoError(t, err)
lblExclHV, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-hv", LabelMembershipType: fleet.LabelMembershipTypeHostVitals})
require.NoError(t, err)
pExcDyn, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-exc-dyn", lblExclDyn), nil)
require.NoError(t, err)
pExcHV, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-exc-hv", lblExclHV), nil)
require.NoError(t, err)
profChecksum := getAndroidProfileChecksum(t, ds, pExcDyn.ProfileUUID)
insertAndroidHostProfileInstalled(t, ds, hostWith.UUID, pExcDyn, profChecksum)
// pExcDyn's label is unknown for both hosts: it stays applicable to hostWith (already
// installed) and withheld from hostWithout. pExcHV's host-vitals label evaluates
// immediately (neither host is a member), so it is applicable to both, which is also what
// flags both hosts as changed and surfaces their full applicable sets.
profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pExcDyn.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pExcDyn.Name, Checksum: profChecksum},
{ProfileUUID: pExcHV.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pExcHV.Name, Checksum: profChecksum},
{ProfileUUID: pExcHV.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pExcHV.Name, Checksum: profChecksum},
}, profs)
// hostWith reports label results and is a member of the exclude label: the preserved
// profile is now authoritatively excluded and must be removed.
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblExclDyn, []uint{hostWith.ID}, fleet.TeamFilter{})
require.NoError(t, err)
hostWith.LabelUpdatedAt = time.Now().UTC().Add(time.Second)
hostWith.PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hostWith)
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pExcDyn.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pExcDyn.Name, Checksum: profChecksum},
}, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pExcHV.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pExcHV.Name, Checksum: profChecksum},
{ProfileUUID: pExcHV.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pExcHV.Name, Checksum: profChecksum},
}, profs)
// hostWithout reports label results and is not a member: pExcDyn now becomes applicable to it.
hostWithout.LabelUpdatedAt = time.Now().UTC().Add(time.Second)
hostWithout.PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hostWithout)
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pExcDyn.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pExcDyn.Name, Checksum: profChecksum},
}, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pExcHV.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pExcHV.Name, Checksum: profChecksum},
{ProfileUUID: pExcDyn.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pExcDyn.Name, Checksum: profChecksum},
{ProfileUUID: pExcHV.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pExcHV.Name, Checksum: profChecksum},
}, profs)
}
// A dynamic include-all label with unknown membership counts as a member only for hosts that
// already have the profile, so adding a label to an installed profile's scope doesn't strip the
// profile while hosts haven't reported yet; a confirmed non-membership of any other include
// label still removes it (see #47865).
func testListMDMAndroidProfilesToSendIncludeAllUnknownLabelPreservation(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
newHostWith, err := ds.NewAndroidHost(ctx, createAndroidHost("enterprise-id-0"), false)
require.NoError(t, err)
hostWith := newHostWith.Host
newHostWithout, err := ds.NewAndroidHost(ctx, createAndroidHost("enterprise-id-1"), false)
require.NoError(t, err)
hostWithout := newHostWithout.Host
// Both labels land in LabelsIncludeAll (no name prefix). Manual membership is always
// known; the dynamic label is unknown for both hosts (created after their last scan).
lblManual, err := ds.NewLabel(ctx, &fleet.Label{Name: "known-manual", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
lblDyn, err := ds.NewLabel(ctx, &fleet.Label{Name: "unknown-dyn", Query: "select 1"})
require.NoError(t, err)
err = ds.AddLabelsToHost(ctx, hostWith.ID, []uint{lblManual.ID})
require.NoError(t, err)
err = ds.AddLabelsToHost(ctx, hostWithout.ID, []uint{lblManual.ID})
require.NoError(t, err)
pInc, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-inc", lblManual, lblDyn), nil)
require.NoError(t, err)
profChecksum := getAndroidProfileChecksum(t, ds, pInc.ProfileUUID)
insertAndroidHostProfileInstalled(t, ds, hostWith.UUID, pInc, profChecksum)
// The dynamic label is unknown for both hosts: hostWith keeps the installed profile (no
// change at all), hostWithout keeps waiting for confirmed membership.
profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Empty(t, profs)
// hostWithout reports label results and is a member of the dynamic label: the profile
// becomes applicable to it.
_, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, *lblDyn, []uint{hostWithout.ID}, fleet.TeamFilter{})
require.NoError(t, err)
hostWithout.LabelUpdatedAt = time.Now().UTC().Add(time.Second)
hostWithout.PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hostWithout)
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pInc.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pInc.Name, Checksum: profChecksum},
}, profs)
// hostWith is confirmed NOT a member of the other (manual) include label: the profile is
// removed even though the dynamic label is still unknown and the profile is installed.
err = ds.RemoveLabelsFromHost(ctx, hostWith.ID, []uint{lblManual.ID})
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pInc.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pInc.Name, Checksum: profChecksum},
}, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pInc.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pInc.Name, Checksum: profChecksum},
}, profs)
}
// Combined include+exclude branches: unknown dynamic labels on either side of a combined
// (include-all + exclude-any, include-any + exclude-any) profile must preserve the host's
// current profile state, same as the single-mode branches (see #47865).
func testListMDMAndroidProfilesToSendCombinedUnknownLabelPreservation(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
newHostWith, err := ds.NewAndroidHost(ctx, createAndroidHost("enterprise-id-0"), false)
require.NoError(t, err)
hostWith := newHostWith.Host
newHostWithout, err := ds.NewAndroidHost(ctx, createAndroidHost("enterprise-id-1"), false)
require.NoError(t, err)
hostWithout := newHostWithout.Host
// Manual labels are always known; the dynamic labels are unknown for both hosts (created
// after their last label scan). Label name prefixes drive the scope mode in
// androidProfileForTest: default -> include-all, "inclany-" -> include-any, "exclude-" -> exclude-any.
lblIncManual, err := ds.NewLabel(ctx, &fleet.Label{Name: "known-manual", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
lblAnyManual, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclany-manual", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
lblIncDyn, err := ds.NewLabel(ctx, &fleet.Label{Name: "unknown-dyn", Query: "select 1"})
require.NoError(t, err)
lblExclDyn, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-dyn", Query: "select 1"})
require.NoError(t, err)
err = ds.AddLabelsToHost(ctx, hostWith.ID, []uint{lblIncManual.ID, lblAnyManual.ID})
require.NoError(t, err)
err = ds.AddLabelsToHost(ctx, hostWithout.ID, []uint{lblIncManual.ID, lblAnyManual.ID})
require.NoError(t, err)
// include-all [manual, dyn] + exclude-any [dyn]
pAll, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("combined-all", lblIncManual, lblIncDyn, lblExclDyn), nil)
require.NoError(t, err)
// include-any [manual] + exclude-any [dyn]
pAny, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("combined-any", lblAnyManual, lblExclDyn), nil)
require.NoError(t, err)
profChecksum := getAndroidProfileChecksum(t, ds, pAll.ProfileUUID)
insertAndroidHostProfileInstalled(t, ds, hostWith.UUID, pAll, profChecksum)
insertAndroidHostProfileInstalled(t, ds, hostWith.UUID, pAny, profChecksum)
// Both dynamic labels are unknown for both hosts: hostWith keeps both installed profiles
// (unknown include counts as member, unknown exclude as non-member) so nothing changes;
// hostWithout keeps waiting (pAll misses the unknown include label, pAny is blocked by the
// unknown exclude label).
profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.Empty(t, profs)
// hostWithout reports label results: member of the include label, not of the exclude
// label. Both combined profiles become applicable to it.
err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{{lblIncDyn.ID, hostWithout.ID}})
require.NoError(t, err)
hostWithout.LabelUpdatedAt = time.Now().UTC().Add(time.Second)
hostWithout.PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hostWithout)
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pAll.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pAll.Name, Checksum: profChecksum},
{ProfileUUID: pAny.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pAny.Name, Checksum: profChecksum},
}, profs)
// hostWith reports label results: member of both dynamic labels. The exclude label is now
// authoritative, so both preserved profiles are removed.
err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{{lblIncDyn.ID, hostWith.ID}, {lblExclDyn.ID, hostWith.ID}})
require.NoError(t, err)
hostWith.LabelUpdatedAt = time.Now().UTC().Add(time.Second)
hostWith.PolicyUpdatedAt = time.Now().UTC()
err = ds.UpdateHost(ctx, hostWith)
require.NoError(t, err)
profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pAll.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pAll.Name, Checksum: profChecksum},
{ProfileUUID: pAny.ProfileUUID, HostUUID: hostWith.UUID, ProfileName: pAny.Name, Checksum: profChecksum},
}, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: pAll.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pAll.Name, Checksum: profChecksum},
{ProfileUUID: pAny.ProfileUUID, HostUUID: hostWithout.UUID, ProfileName: pAny.Name, Checksum: profChecksum},
}, profs)
}
func testGetMDMAndroidProfilesContents(t *testing.T, ds *Datastore) {
ctx := t.Context()
p1 := androidProfileForTest("p1")
p1.RawJSON = []byte(`{"v": 1}`)
p2 := androidProfileForTest("p2")
p2.RawJSON = []byte(`{"v": 2}`)
p3 := androidProfileForTest("p3")
p3.RawJSON = []byte(`{"v": 3}`)
p1, err := ds.NewMDMAndroidConfigProfile(ctx, *p1, nil)
require.NoError(t, err)
p2, err = ds.NewMDMAndroidConfigProfile(ctx, *p2, nil)
require.NoError(t, err)
p3, err = ds.NewMDMAndroidConfigProfile(ctx, *p3, nil)
require.NoError(t, err)
cases := []struct {
uuids []string
want map[string]json.RawMessage
}{
{[]string{}, nil},
{nil, nil},
{[]string{p1.ProfileUUID}, map[string]json.RawMessage{p1.ProfileUUID: p1.RawJSON}},
{[]string{p1.ProfileUUID, p2.ProfileUUID}, map[string]json.RawMessage{
p1.ProfileUUID: p1.RawJSON,
p2.ProfileUUID: p2.RawJSON,
}},
{[]string{p1.ProfileUUID, p2.ProfileUUID, p3.ProfileUUID}, map[string]json.RawMessage{
p1.ProfileUUID: p1.RawJSON,
p2.ProfileUUID: p2.RawJSON,
p3.ProfileUUID: p3.RawJSON,
}},
{[]string{p1.ProfileUUID, p2.ProfileUUID, "no-such-uuid"}, map[string]json.RawMessage{
p1.ProfileUUID: p1.RawJSON,
p2.ProfileUUID: p2.RawJSON,
}},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%v", c.uuids), func(t *testing.T) {
out, err := ds.GetMDMAndroidProfilesContents(ctx, c.uuids)
require.NoError(t, err)
require.Equal(t, c.want, out)
})
}
}
func testBulkUpsertMDMAndroidHostProfiles(t *testing.T, ds *Datastore) {
testBulkUpsertMDMAndroidHostProfilesN(t, ds, 0)
}
func testBulkUpsertMDMAndroidHostProfiles2(t *testing.T, ds *Datastore) {
testBulkUpsertMDMAndroidHostProfilesN(t, ds, 2)
}
func testBulkUpsertMDMAndroidHostProfiles3(t *testing.T, ds *Datastore) {
testBulkUpsertMDMAndroidHostProfilesN(t, ds, 3)
}
func testBulkUpsertMDMAndroidHostProfilesN(t *testing.T, ds *Datastore, batchSize int) {
test.AddBuiltinLabels(t, ds)
ctx := t.Context()
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team"})
require.NoError(t, err)
// Create some hosts and some profiles
hosts := make([]*fleet.Host, 3)
for i := range hosts {
androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", i))
newHost, err := ds.NewAndroidHost(ctx, androidHost, false)
require.NoError(t, err)
hosts[i] = newHost.Host
if i == len(hosts)-1 {
// last host is in a team
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hosts[i].ID}))
require.NoError(t, err)
}
}
profiles := make([]*fleet.MDMAndroidConfigProfile, 3)
for i := range profiles {
p := androidProfileForTest(fmt.Sprintf("profile-%d", i))
if i == len(profiles)-1 {
// last profile is for a team
p.TeamID = &tm.ID
}
p, err := ds.NewMDMAndroidConfigProfile(ctx, *p, nil)
require.NoError(t, err)
profiles[i] = p
}
// all profiles use the same raw JSON, so they share the same checksum
profChecksum := getAndroidProfileChecksum(t, ds, profiles[0].ProfileUUID)
err = ds.BulkUpsertMDMAndroidHostProfiles(ctx, nil)
require.NoError(t, err)
ds.testUpsertMDMDesiredProfilesBatchSize = batchSize
t.Cleanup(func() { ds.testUpsertMDMDesiredProfilesBatchSize = 0 })
hostProfiles, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[0].Name, Checksum: profChecksum},
{ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[1].Name, Checksum: profChecksum},
{ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[0].Name, Checksum: profChecksum},
{ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[1].Name, Checksum: profChecksum},
{ProfileUUID: profiles[2].ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: profiles[2].Name, Checksum: profChecksum},
}, hostProfiles)
// mark all installed for hosts 0, profile 1 failed for host 1
err = ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hosts[0].UUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: ptr.Int(1),
Checksum: profChecksum,
},
{
HostUUID: hosts[0].UUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: ptr.Int(1),
Checksum: profChecksum,
},
{
HostUUID: hosts[1].UUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryFailed,
IncludedInPolicyVersion: ptr.Int(1),
Checksum: profChecksum,
},
})
require.NoError(t, err)
hostProfiles, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
// because host 1 still has a missing profile, it must resend both (as it merged them)
{ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[0].Name, Checksum: profChecksum},
{ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[1].Name, Checksum: profChecksum},
{ProfileUUID: profiles[2].ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: profiles[2].Name, Checksum: profChecksum},
}, hostProfiles)
// mark host 0 profile 1 as NULL, host 1 profile 0 as installed (so both are now installed), and host 2 profile 2 as installed
err = ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hosts[0].UUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: nil,
IncludedInPolicyVersion: ptr.Int(1),
Checksum: profChecksum,
},
{
HostUUID: hosts[1].UUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: ptr.Int(1),
Checksum: profChecksum,
},
{
HostUUID: hosts[2].UUID,
ProfileUUID: profiles[2].ProfileUUID,
ProfileName: profiles[2].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: ptr.Int(1),
Checksum: profChecksum,
},
})
require.NoError(t, err)
hostProfiles, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.Empty(t, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
// host 0 now has a profile not installed, so it needs to resend both
{ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[0].Name, Checksum: profChecksum},
{ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[1].Name, Checksum: profChecksum},
// host 1 now has both delivered, nothing to resend
// host 2 profile is delivered, nothing to resend
}, hostProfiles)
// delete profile 2, which will cause host 2 to be resent as "no profiles" to remove it as it was delivered
err = ds.DeleteMDMAndroidConfigProfile(ctx, profiles[2].ProfileUUID)
require.NoError(t, err)
hostProfiles, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx, "", 0)
require.NoError(t, err)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: profiles[2].ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: profiles[2].Name, Checksum: profChecksum},
}, toRemoveProfs)
require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{
{ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[0].Name, Checksum: profChecksum},
{ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[1].Name, Checksum: profChecksum},
}, hostProfiles)
}
func testGetAndroidPolicyRequestByUUID(t *testing.T, ds *Datastore) {
ctx := t.Context()
policyRequestUUID := uuid.New().String()
t.Run("Returns not found", func(t *testing.T) {
policyRequest, err := ds.GetAndroidPolicyRequestByUUID(ctx, policyRequestUUID)
require.Contains(t, err.Error(), common_mysql.NotFound("AndroidPolicyRequest").WithName(policyRequestUUID).Error())
require.Nil(t, policyRequest)
})
t.Run("Correctly retrieves the policy request", func(t *testing.T) {
// Create a test policy request
err := ds.NewAndroidPolicyRequest(ctx, &android.MDMAndroidPolicyRequest{
RequestUUID: policyRequestUUID,
Payload: json.RawMessage(`{"key": "value"}`),
})
require.NoError(t, err)
// Retrieve the policy request by UUID
policyRequest, err := ds.GetAndroidPolicyRequestByUUID(ctx, policyRequestUUID)
require.NoError(t, err)
require.NotNil(t, policyRequest)
require.Equal(t, policyRequestUUID, policyRequest.RequestUUID)
})
}
func testMDMAndroidCommandCRUD(t *testing.T, ds *Datastore) {
ctx := t.Context()
t.Run("Not found returns typed NotFound error for both lookups", func(t *testing.T) {
_, err := ds.GetMDMAndroidCommandByUUID(ctx, "missing-uuid")
require.Contains(t, err.Error(), common_mysql.NotFound("MDMAndroidCommand").WithName("missing-uuid").Error())
_, err = ds.GetMDMAndroidCommandByOperationName(ctx, "missing-op")
require.Contains(t, err.Error(), common_mysql.NotFound("MDMAndroidCommand").WithName("missing-op").Error())
})
t.Run("Update on missing row returns NotFound", func(t *testing.T) {
err := ds.UpdateMDMAndroidCommandStatus(ctx, "missing-uuid", string(android.MDMAndroidCommandStatusAcknowledged), nil, nil)
require.Contains(t, err.Error(), common_mysql.NotFound("MDMAndroidCommand").WithName("missing-uuid").Error())
})
t.Run("Insert, read by both keys, then transition to acknowledged", func(t *testing.T) {
cmd := &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: "host-uuid-1",
OperationName: "enterprises/E1/devices/D1/operations/100",
CommandType: string(android.MDMAndroidCommandTypeLock),
Status: string(android.MDMAndroidCommandStatusPending),
}
require.NoError(t, ds.NewMDMAndroidCommand(ctx, cmd))
byUUID, err := ds.GetMDMAndroidCommandByUUID(ctx, cmd.CommandUUID)
require.NoError(t, err)
require.Equal(t, cmd.HostUUID, byUUID.HostUUID)
require.Equal(t, cmd.OperationName, byUUID.OperationName)
require.Equal(t, string(android.MDMAndroidCommandTypeLock), byUUID.CommandType)
require.Equal(t, string(android.MDMAndroidCommandStatusPending), byUUID.Status)
require.False(t, byUUID.ErrorCode.Valid)
require.False(t, byUUID.ErrorMessage.Valid)
byOp, err := ds.GetMDMAndroidCommandByOperationName(ctx, cmd.OperationName)
require.NoError(t, err)
require.Equal(t, cmd.CommandUUID, byOp.CommandUUID)
require.NoError(t, ds.UpdateMDMAndroidCommandStatus(ctx, cmd.CommandUUID,
string(android.MDMAndroidCommandStatusAcknowledged), nil, nil))
acked, err := ds.GetMDMAndroidCommandByUUID(ctx, cmd.CommandUUID)
require.NoError(t, err)
require.Equal(t, string(android.MDMAndroidCommandStatusAcknowledged), acked.Status)
require.False(t, acked.ErrorCode.Valid)
require.False(t, acked.ErrorMessage.Valid)
})
t.Run("Update writes error_code and error_message when provided", func(t *testing.T) {
cmdUUID := uuid.NewString()
require.NoError(t, ds.NewMDMAndroidCommand(ctx, &android.MDMAndroidCommand{
CommandUUID: cmdUUID,
HostUUID: "host-uuid-2",
OperationName: "enterprises/E1/devices/D1/operations/200",
CommandType: string(android.MDMAndroidCommandTypeWipe),
Status: string(android.MDMAndroidCommandStatusPending),
}))
errCode := "UNSUPPORTED"
errMsg := "device does not support WIPE"
require.NoError(t, ds.UpdateMDMAndroidCommandStatus(ctx, cmdUUID,
string(android.MDMAndroidCommandStatusError), &errCode, &errMsg))
got, err := ds.GetMDMAndroidCommandByUUID(ctx, cmdUUID)
require.NoError(t, err)
require.Equal(t, string(android.MDMAndroidCommandStatusError), got.Status)
require.True(t, got.ErrorCode.Valid)
require.Equal(t, errCode, got.ErrorCode.V)
require.True(t, got.ErrorMessage.Valid)
require.Equal(t, errMsg, got.ErrorMessage.V)
})
t.Run("Oversized error_message is truncated to fit VARCHAR(1024)", func(t *testing.T) {
cmdUUID := uuid.NewString()
require.NoError(t, ds.NewMDMAndroidCommand(ctx, &android.MDMAndroidCommand{
CommandUUID: cmdUUID,
HostUUID: "host-uuid-trim",
OperationName: "enterprises/E1/devices/D1/operations/trim",
CommandType: string(android.MDMAndroidCommandTypeLock),
Status: string(android.MDMAndroidCommandStatusPending),
}))
huge := strings.Repeat("x", 5000)
errCode := "13"
require.NoError(t, ds.UpdateMDMAndroidCommandStatus(ctx, cmdUUID,
string(android.MDMAndroidCommandStatusError), &errCode, &huge))
got, err := ds.GetMDMAndroidCommandByUUID(ctx, cmdUUID)
require.NoError(t, err)
require.True(t, got.ErrorMessage.Valid)
require.Len(t, got.ErrorMessage.V, 1024, "error_message should be truncated to the column's VARCHAR(1024) limit")
})
t.Run("Duplicate operation_name fails", func(t *testing.T) {
// operation_name is UNIQUE so Pub/Sub COMMAND correlation can stay a single-row lookup.
opName := "enterprises/E1/devices/D1/operations/dup"
require.NoError(t, ds.NewMDMAndroidCommand(ctx, &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: "host-uuid-dup-1",
OperationName: opName,
CommandType: string(android.MDMAndroidCommandTypeLock),
Status: string(android.MDMAndroidCommandStatusPending),
}))
err := ds.NewMDMAndroidCommand(ctx, &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: "host-uuid-dup-2",
OperationName: opName,
CommandType: string(android.MDMAndroidCommandTypeLock),
Status: string(android.MDMAndroidCommandStatusPending),
})
require.Error(t, err)
require.True(t, IsDuplicate(err), "expected a Duplicate-entry error for the UNIQUE operation_name constraint")
})
}
func testListPendingMDMAndroidCommands(t *testing.T, ds *Datastore) {
ctx := t.Context()
// insertCommand creates a command row and backdates created_at so the age cutoff can be exercised
// without waiting. Returns the command_uuid.
insertCommand := func(t *testing.T, status string, age time.Duration) string {
cmdUUID := uuid.NewString()
require.NoError(t, ds.NewMDMAndroidCommand(ctx, &android.MDMAndroidCommand{
CommandUUID: cmdUUID,
HostUUID: "host-" + cmdUUID,
OperationName: "enterprises/E1/devices/D1/operations/" + cmdUUID,
CommandType: string(android.MDMAndroidCommandTypeLock),
Status: status,
}))
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`UPDATE mdm_android_commands SET created_at = NOW(6) - INTERVAL ? SECOND WHERE command_uuid = ?`,
int(age.Seconds()), cmdUUID)
return err
})
return cmdUUID
}
uuidsOf := func(cmds []*android.MDMAndroidCommand) []string {
got := make([]string, 0, len(cmds))
for _, cmd := range cmds {
got = append(got, cmd.CommandUUID)
}
return got
}
oldest := insertCommand(t, string(android.MDMAndroidCommandStatusPending), 72*time.Hour)
middle := insertCommand(t, string(android.MDMAndroidCommandStatusPending), 48*time.Hour)
newest := insertCommand(t, string(android.MDMAndroidCommandStatusPending), 25*time.Hour)
tooRecent := insertCommand(t, string(android.MDMAndroidCommandStatusPending), time.Hour)
acknowledged := insertCommand(t, string(android.MDMAndroidCommandStatusAcknowledged), 48*time.Hour)
errored := insertCommand(t, string(android.MDMAndroidCommandStatusError), 48*time.Hour)
t.Run("returns only pending rows older than the cutoff, oldest first", func(t *testing.T) {
cmds, err := ds.ListPendingMDMAndroidCommands(ctx, time.Now().Add(-24*time.Hour), 100)
require.NoError(t, err)
require.Equal(t, []string{oldest, middle, newest}, uuidsOf(cmds))
require.NotContains(t, uuidsOf(cmds), tooRecent)
require.NotContains(t, uuidsOf(cmds), acknowledged)
require.NotContains(t, uuidsOf(cmds), errored)
})
t.Run("limit caps the batch to the oldest rows", func(t *testing.T) {
cmds, err := ds.ListPendingMDMAndroidCommands(ctx, time.Now().Add(-24*time.Hour), 2)
require.NoError(t, err)
require.Equal(t, []string{oldest, middle}, uuidsOf(cmds))
})
t.Run("returns all fields needed to reconcile", func(t *testing.T) {
cmds, err := ds.ListPendingMDMAndroidCommands(ctx, time.Now().Add(-24*time.Hour), 1)
require.NoError(t, err)
require.Len(t, cmds, 1)
assert.Equal(t, oldest, cmds[0].CommandUUID)
assert.Equal(t, "host-"+oldest, cmds[0].HostUUID)
assert.Equal(t, "enterprises/E1/devices/D1/operations/"+oldest, cmds[0].OperationName)
assert.Equal(t, string(android.MDMAndroidCommandTypeLock), cmds[0].CommandType)
assert.Equal(t, string(android.MDMAndroidCommandStatusPending), cmds[0].Status)
// created_at drives the not-found grace period in the reconciler, so it has to come back
// populated. Only assert it predates the cutoff -- an exact age would be at the mercy of clock
// skew between the app and the database.
assert.False(t, cmds[0].CreatedAt.IsZero())
assert.True(t, cmds[0].CreatedAt.Before(time.Now().Add(-24*time.Hour)))
})
t.Run("no matching rows returns an empty slice", func(t *testing.T) {
cmds, err := ds.ListPendingMDMAndroidCommands(ctx, time.Now().Add(-365*24*time.Hour), 100)
require.NoError(t, err)
require.Empty(t, cmds)
})
}
// newBareAndroidHostForTest inserts a minimal android-platform host row. Use this for tests
// that exercise the host_mdm_actions layer and don't need a populated android_devices row
// (use createAndroidHost + ds.NewAndroidHost for that).
func newBareAndroidHostForTest(t *testing.T, ds *Datastore, hostname string) *fleet.Host {
t.Helper()
h, err := ds.NewHost(t.Context(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(uuid.NewString()),
UUID: uuid.NewString(),
Hostname: hostname,
Platform: "android",
})
require.NoError(t, err)
return h
}
func testLockWipeHostViaAndroidMDM(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := newBareAndroidHostForTest(t, ds, "android-lockwipe-helper-test")
t.Run("Lock writes both rows atomically and reports pending", func(t *testing.T) {
cmd := &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: host.UUID,
OperationName: "enterprises/E/devices/D/operations/lock-1",
CommandType: string(android.MDMAndroidCommandTypeLock),
Status: string(android.MDMAndroidCommandStatusPending),
}
require.NoError(t, ds.LockHostViaAndroidMDM(ctx, host, cmd))
got, err := ds.GetMDMAndroidCommandByUUID(ctx, cmd.CommandUUID)
require.NoError(t, err)
require.Equal(t, string(android.MDMAndroidCommandTypeLock), got.CommandType)
require.Equal(t, string(android.MDMAndroidCommandStatusPending), got.Status)
status, err := ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
require.Equal(t, fleet.PendingActionLock, status.PendingAction())
require.Equal(t, "android", status.HostFleetPlatform)
})
t.Run("Wipe overwrites wipe_ref on subsequent calls (re-queue)", func(t *testing.T) {
first := &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: host.UUID,
OperationName: "enterprises/E/devices/D/operations/wipe-1",
CommandType: string(android.MDMAndroidCommandTypeWipe),
Status: string(android.MDMAndroidCommandStatusPending),
}
require.NoError(t, ds.WipeHostViaAndroidMDM(ctx, host, first))
second := &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: host.UUID,
OperationName: "enterprises/E/devices/D/operations/wipe-2",
CommandType: string(android.MDMAndroidCommandTypeWipe),
Status: string(android.MDMAndroidCommandStatusPending),
}
require.NoError(t, ds.WipeHostViaAndroidMDM(ctx, host, second))
// Both command rows persist (audit trail).
_, err := ds.GetMDMAndroidCommandByUUID(ctx, first.CommandUUID)
require.NoError(t, err)
_, err = ds.GetMDMAndroidCommandByUUID(ctx, second.CommandUUID)
require.NoError(t, err)
// host_mdm_actions.wipe_ref points at the latest one.
status, err := ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
require.NotNil(t, status.WipeMDMCommand)
require.Equal(t, second.CommandUUID, status.WipeMDMCommand.CommandUUID)
})
t.Run("ClearPasscode writes the row and reports pending clear-passcode", func(t *testing.T) {
// Fresh host: the parent test has Lock + Wipe pending on `host`, which would dominate
// PendingAction() priority over clear_passcode.
cpHost := newBareAndroidHostForTest(t, ds, "android-clear-passcode-helper-test")
cmd := &android.MDMAndroidCommand{
CommandUUID: uuid.NewString(),
HostUUID: cpHost.UUID,
OperationName: "enterprises/E/devices/" + cpHost.UUID + "/operations/clear-passcode-1",
CommandType: string(android.MDMAndroidCommandTypeResetPassword),
Status: string(android.MDMAndroidCommandStatusPending),
}
require.NoError(t, ds.ClearPasscodeHostViaAndroidMDM(ctx, cpHost, cmd))
got, err := ds.GetMDMAndroidCommandByUUID(ctx, cmd.CommandUUID)
require.NoError(t, err)
require.Equal(t, string(android.MDMAndroidCommandTypeResetPassword), got.CommandType)
require.Equal(t, string(android.MDMAndroidCommandStatusPending), got.Status)
status, err := ds.GetHostLockWipeStatus(ctx, cpHost)
require.NoError(t, err)
require.NotNil(t, status.ClearPasscodeMDMCommand)
require.Equal(t, cmd.CommandUUID, status.ClearPasscodeMDMCommand.CommandUUID)
require.True(t, status.IsPendingClearPasscode())
require.Equal(t, fleet.PendingActionClearPasscode, status.PendingAction())
})
}
func testListHostMDMAndroidProfilesPendingInstallWithVersion(t *testing.T, ds *Datastore) {
ctx := t.Context()
profiles := make([]*fleet.MDMAndroidConfigProfile, 3)
for i := range profiles {
p := androidProfileForTest(fmt.Sprintf("profile-%d", i))
p, err := ds.NewMDMAndroidConfigProfile(ctx, *p, nil)
require.NoError(t, err)
profiles[i] = p
}
hostUUID := uuid.NewString()
clearOutHostMDMAndroidProfilesTable := func() {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "DELETE FROM host_mdm_android_profiles WHERE host_uuid = ?", hostUUID)
return err
})
}
t.Run("Does not list other install statuses", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryVerified,
IncludedInPolicyVersion: policyVersion,
},
{
HostUUID: hostUUID,
ProfileUUID: profiles[2].ProfileUUID,
ProfileName: profiles[2].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryVerifying,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
hostProfiles, err := ds.ListHostMDMAndroidProfilesPendingOrFailedInstallWithVersion(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
require.Len(t, hostProfiles, 0)
})
t.Run("Does not list higher versions than passed", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(2)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryFailed,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
hostProfiles, err := ds.ListHostMDMAndroidProfilesPendingOrFailedInstallWithVersion(ctx, hostUUID, int64(*policyVersion-1))
require.NoError(t, err)
require.Len(t, hostProfiles, 0)
})
t.Run("Does not list remove operation", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryFailed,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
hostProfiles, err := ds.ListHostMDMAndroidProfilesPendingOrFailedInstallWithVersion(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
require.Len(t, hostProfiles, 0)
})
t.Run("Does list pending install profiles with version less than or equal to applied policy version", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
hostProfiles, err := ds.ListHostMDMAndroidProfilesPendingOrFailedInstallWithVersion(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
require.Len(t, hostProfiles, 1)
require.Equal(t, &fleet.MDMDeliveryPending, hostProfiles[0].Status)
require.Equal(t, fleet.MDMOperationTypeInstall, hostProfiles[0].OperationType)
require.EqualValues(t, policyVersion, hostProfiles[0].IncludedInPolicyVersion)
})
t.Run("Does list pending install profiles and failed install profiles with can_reverify", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: policyVersion,
},
{
HostUUID: hostUUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryFailed,
IncludedInPolicyVersion: policyVersion,
CanReverify: true,
},
{
HostUUID: hostUUID,
ProfileUUID: profiles[2].ProfileUUID,
ProfileName: profiles[2].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryFailed,
IncludedInPolicyVersion: policyVersion,
CanReverify: false,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
hostProfiles, err := ds.ListHostMDMAndroidProfilesPendingOrFailedInstallWithVersion(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
require.Len(t, hostProfiles, 2)
require.ElementsMatch(t,
[]*fleet.MDMDeliveryStatus{&fleet.MDMDeliveryPending, &fleet.MDMDeliveryFailed},
[]*fleet.MDMDeliveryStatus{hostProfiles[0].Status, hostProfiles[1].Status},
)
require.Equal(t, fleet.MDMOperationTypeInstall, hostProfiles[0].OperationType)
require.EqualValues(t, policyVersion, hostProfiles[0].IncludedInPolicyVersion)
require.Equal(t, fleet.MDMOperationTypeInstall, hostProfiles[1].OperationType)
require.EqualValues(t, policyVersion, hostProfiles[1].IncludedInPolicyVersion)
})
}
func testBulkDeleteMDMAndroidHostProfiles(t *testing.T, ds *Datastore) {
ctx := t.Context()
profiles := make([]*fleet.MDMAndroidConfigProfile, 3)
for i := range profiles {
p := androidProfileForTest(fmt.Sprintf("profile-%d", i))
p, err := ds.NewMDMAndroidConfigProfile(ctx, *p, nil)
require.NoError(t, err)
profiles[i] = p
}
hostUUID := uuid.NewString()
clearOutHostMDMAndroidProfilesTable := func() {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "DELETE FROM host_mdm_android_profiles WHERE host_uuid = ?", hostUUID)
return err
})
}
listAllHostMDMAndroidProfiles := func() []*fleet.MDMAndroidProfilePayload {
var hostProfiles []*fleet.MDMAndroidProfilePayload
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
err := sqlx.SelectContext(ctx, q, &hostProfiles, "SELECT profile_uuid, host_uuid, profile_name, operation_type, status, detail, included_in_policy_version, policy_request_uuid, device_request_uuid, request_fail_count FROM host_mdm_android_profiles")
require.NoError(t, err)
return err
})
return hostProfiles
}
t.Run("Does not delete profiles not associated with host", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
// Act
err = ds.BulkDeleteMDMAndroidHostProfiles(ctx, uuid.NewString(), int64(*policyVersion))
require.NoError(t, err)
// Assert
hostProfiles := listAllHostMDMAndroidProfiles()
require.Len(t, hostProfiles, 1)
})
t.Run("Does not delete install operation types", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeInstall,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
// Act
err = ds.BulkDeleteMDMAndroidHostProfiles(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
// Assert
hostProfiles := listAllHostMDMAndroidProfiles()
require.Len(t, hostProfiles, 1)
})
t.Run("Does not delete other statuses with remove operation", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(1)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryVerifying,
IncludedInPolicyVersion: policyVersion,
},
{
HostUUID: hostUUID,
ProfileUUID: profiles[2].ProfileUUID,
ProfileName: profiles[2].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryVerified,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
// Act
err = ds.BulkDeleteMDMAndroidHostProfiles(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
// Assert
hostProfiles := listAllHostMDMAndroidProfiles()
require.Len(t, hostProfiles, 2)
})
t.Run("Does not delete profiles with higher policy version than passed", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(2)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
// Act
err = ds.BulkDeleteMDMAndroidHostProfiles(ctx, hostUUID, int64(*policyVersion-1))
require.NoError(t, err)
// Assert
hostProfiles := listAllHostMDMAndroidProfiles()
require.Len(t, hostProfiles, 1)
})
t.Run("Deletes pending or failed remove profiles with policy version lower than or equal to passed", func(t *testing.T) {
// Arrange
policyVersion := ptr.Int(2)
err := ds.BulkUpsertMDMAndroidHostProfiles(ctx, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: hostUUID,
ProfileUUID: profiles[0].ProfileUUID,
ProfileName: profiles[0].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: policyVersion,
},
{
HostUUID: hostUUID,
ProfileUUID: profiles[1].ProfileUUID,
ProfileName: profiles[1].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryPending,
IncludedInPolicyVersion: ptr.Int(*policyVersion - 1),
},
{
HostUUID: hostUUID,
ProfileUUID: profiles[2].ProfileUUID,
ProfileName: profiles[2].Name,
OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryFailed,
IncludedInPolicyVersion: policyVersion,
},
})
require.NoError(t, err)
t.Cleanup(clearOutHostMDMAndroidProfilesTable)
// Act
err = ds.BulkDeleteMDMAndroidHostProfiles(ctx, hostUUID, int64(*policyVersion))
require.NoError(t, err)
// Assert
hostProfiles := listAllHostMDMAndroidProfiles()
require.Len(t, hostProfiles, 0)
})
}
func testNewAndroidHostWithIdP(t *testing.T, ds *Datastore) {
ctx := t.Context()
test.AddBuiltinLabels(t, ds)
// create IdP account... InsertMDMIdPAccount generates its own UUID
idpAccount := &fleet.MDMIdPAccount{
Username: "john.doe",
Fullname: "John Doe",
Email: "john.doe@example.com",
}
err := ds.InsertMDMIdPAccount(ctx, idpAccount)
require.NoError(t, err)
// get the actual UUID that was generated
insertedAccount, err := ds.GetMDMIdPAccountByEmail(ctx, "john.doe@example.com")
require.NoError(t, err)
require.NotNil(t, insertedAccount)
idpAccount.UUID = insertedAccount.UUID
// create Android host
const enterpriseSpecificID = "enterprise_with_idp"
host := createAndroidHost(enterpriseSpecificID)
host.Host.UUID = "test-host-uuid" // Use a specific UUID for testing
result, err := ds.NewAndroidHost(ctx, host, false)
require.NoError(t, err)
require.NotZero(t, result.Host.ID)
// associate host with IdP account, triggering reconciliation
err = ds.AssociateHostMDMIdPAccount(ctx, "test-host-uuid", idpAccount.UUID)
require.NoError(t, err)
// host_emails table has IdP email
emails, err := ds.GetHostEmails(ctx, "test-host-uuid", fleet.DeviceMappingMDMIdpAccounts)
require.NoError(t, err)
require.Len(t, emails, 1)
assert.Equal(t, "john.doe@example.com", emails[0])
// is reconciliation idempotent?
err = ds.AssociateHostMDMIdPAccount(ctx, "test-host-uuid", idpAccount.UUID)
require.NoError(t, err)
// still only one email (no duplicates)
emails, err = ds.GetHostEmails(ctx, "test-host-uuid", fleet.DeviceMappingMDMIdpAccounts)
require.NoError(t, err)
require.Len(t, emails, 1, "Should still have exactly one email after reassociation")
assert.Equal(t, "john.doe@example.com", emails[0])
// remove IdP account association and trigger reconciliation
_, err = ds.writer(ctx).ExecContext(ctx,
`DELETE FROM host_mdm_idp_accounts WHERE host_uuid = ?`,
"test-host-uuid")
require.NoError(t, err)
// test cleanup (in production this would happen on re-enrollment)
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
_, err := reconcileHostEmailsFromMdmIdpAccountsDB(ctx, tx, ds.logger, result.Host.ID)
return err
})
require.NoError(t, err)
// host_emails table no longer has IdP email
emails, err = ds.GetHostEmails(ctx, "test-host-uuid", fleet.DeviceMappingMDMIdpAccounts)
require.NoError(t, err)
require.Empty(t, emails, "IdP email should be removed when association is deleted")
}
func testAndroidBYODDetection(t *testing.T, ds *Datastore) {
ctx := context.Background()
test.AddBuiltinLabels(t, ds)
// Test 1: Android host with non-empty UUID (BYOD/personal device)
t.Run("personal enrollment with UUID", func(t *testing.T) {
const enterpriseID = "test-enterprise-id-byod"
host := createAndroidHost(enterpriseID)
// Ensure UUID is set (createAndroidHost already does this)
require.NotEmpty(t, host.Host.UUID)
require.Equal(t, enterpriseID, host.Host.UUID)
result, err := ds.NewAndroidHost(ctx, host, false)
require.NoError(t, err)
require.NotZero(t, result.Host.ID)
// Query host_mdm table directly to verify is_personal_enrollment = 1
var isPersonalEnrollment bool
err = sqlx.GetContext(ctx, ds.reader(ctx), &isPersonalEnrollment,
`SELECT is_personal_enrollment FROM host_mdm WHERE host_id = ?`,
result.Host.ID)
require.NoError(t, err)
assert.True(t, isPersonalEnrollment, "BYOD device with UUID should have is_personal_enrollment = 1")
})
// Test 2: Android host without UUID (company-owned device)
t.Run("company enrollment", func(t *testing.T) {
const enterpriseID = "test-enterprise-id-company"
host := createAndroidHost(enterpriseID)
result, err := ds.NewAndroidHost(ctx, host, true)
require.NoError(t, err)
require.NotZero(t, result.Host.ID)
// Query host_mdm table directly to verify is_personal_enrollment = 0
var isPersonalEnrollment bool
err = sqlx.GetContext(ctx, ds.reader(ctx), &isPersonalEnrollment,
`SELECT is_personal_enrollment FROM host_mdm WHERE host_id = ?`,
result.Host.ID)
require.NoError(t, err)
assert.False(t, isPersonalEnrollment, "Company device should have is_personal_enrollment = 0")
})
// Test 3: Verify update path also sets personal enrollment correctly
t.Run("update existing host enrollment status", func(t *testing.T) {
// Create a host initially without UUID
const enterpriseID = "test-enterprise-id-update"
host := createAndroidHost(enterpriseID)
host.Host.UUID = ""
result, err := ds.NewAndroidHost(ctx, host, true)
require.NoError(t, err)
require.NotZero(t, result.Host.ID)
// Initially should not be personal enrollment
var isPersonalEnrollment bool
err = sqlx.GetContext(ctx, ds.reader(ctx), &isPersonalEnrollment,
`SELECT is_personal_enrollment FROM host_mdm WHERE host_id = ?`,
result.Host.ID)
require.NoError(t, err)
assert.False(t, isPersonalEnrollment, "Initially should not be personal enrollment")
// Update the host with a UUID (simulating re-enrollment as BYOD)
result.Host.UUID = enterpriseID
err = ds.UpdateAndroidHost(ctx, result, true, false) // fromEnroll = true to trigger MDM info update
require.NoError(t, err)
// Now should be marked as personal enrollment
err = sqlx.GetContext(ctx, ds.reader(ctx), &isPersonalEnrollment,
`SELECT is_personal_enrollment FROM host_mdm WHERE host_id = ?`,
result.Host.ID)
require.NoError(t, err)
assert.True(t, isPersonalEnrollment, "After update with UUID should have is_personal_enrollment = 1")
})
}
// NEW TEST: verify single-host unenroll updates host_mdm correctly
func testSetAndroidHostEnrolled(t *testing.T, ds *Datastore) {
appCfg, err := ds.AppConfig(testCtx())
require.NoError(t, err)
appCfg.ServerSettings.ServerURL = "https://mdm.example.com"
require.NoError(t, ds.SaveAppConfig(testCtx(), appCfg))
// Create a BYO Android host (companyOwned=false) -> enrolled host_mdm row.
esid := "enterprise-" + uuid.NewString()
res, err := ds.NewAndroidHost(testCtx(), createAndroidHost(esid), false)
require.NoError(t, err)
// Already enrolled: no-op, returns false.
didEnroll, err := ds.SetAndroidHostEnrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
require.False(t, didEnroll, "SetAndroidHostEnrolled must be a no-op when the host is already enrolled")
// Unenroll, then recover.
unenrolled, err := ds.SetAndroidHostUnenrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
require.True(t, unenrolled)
didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
require.True(t, didEnroll, "SetAndroidHostEnrolled must restore enrollment for an unenrolled host")
hostMDM, err := ds.GetHostMDM(testCtx(), res.Host.ID)
require.NoError(t, err)
require.True(t, hostMDM.Enrolled, "host_mdm.enrolled must be restored to 1")
require.Equal(t, "https://mdm.example.com", hostMDM.ServerURL, "server_url must be restored")
require.True(t, hostMDM.IsPersonalEnrollment, "BYO recovery must preserve is_personal_enrollment")
// Calling again is a no-op.
didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
require.False(t, didEnroll)
// Unknown host has no host_mdm row: no-op, no error.
didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), 999999)
require.NoError(t, err)
require.False(t, didEnroll)
// COBO recovery must preserve is_personal_enrollment=0 even though the recovery does
// not know the ownership (it is derived from the existing row, not the status payload).
coboESID := "enterprise-cobo-" + uuid.NewString()
cobo, err := ds.NewAndroidHost(testCtx(), createAndroidHost(coboESID), true /* companyOwned */)
require.NoError(t, err)
coboMDM, err := ds.GetHostMDM(testCtx(), cobo.Host.ID)
require.NoError(t, err)
require.False(t, coboMDM.IsPersonalEnrollment, "fresh COBO enrollment is not a personal enrollment")
unenrolled, err = ds.SetAndroidHostUnenrolled(testCtx(), cobo.Host.ID)
require.NoError(t, err)
require.True(t, unenrolled)
didEnroll, err = ds.SetAndroidHostEnrolled(testCtx(), cobo.Host.ID)
require.NoError(t, err)
require.True(t, didEnroll)
coboMDM, err = ds.GetHostMDM(testCtx(), cobo.Host.ID)
require.NoError(t, err)
require.True(t, coboMDM.Enrolled)
require.False(t, coboMDM.IsPersonalEnrollment, "COBO recovery must not reclassify the host as personal")
}
func testAndroidPubSubDedupState(t *testing.T, ds *Datastore) {
esid := "enterprise-" + uuid.NewString()
res, err := ds.NewAndroidHost(testCtx(), createAndroidHost(esid), false)
require.NoError(t, err)
hostID := res.Host.ID
// Fresh host: no recorded state.
messageID, eventTime, err := ds.GetAndroidPubSubDedupState(testCtx(), hostID)
require.NoError(t, err)
require.Empty(t, messageID)
require.Nil(t, eventTime)
// Record a messageId + event time.
t1 := time.Now().UTC().Truncate(time.Microsecond)
require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-1", &t1))
messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID)
require.NoError(t, err)
require.Equal(t, "msg-1", messageID)
require.NotNil(t, eventTime)
require.WithinDuration(t, t1, *eventTime, time.Millisecond)
// Overwrite with a newer message.
t2 := t1.Add(time.Hour)
require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-2", &t2))
messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID)
require.NoError(t, err)
require.Equal(t, "msg-2", messageID)
require.WithinDuration(t, t2, *eventTime, time.Millisecond)
// A nil event time records the messageId but preserves the timestamp baseline. Clearing
// it to NULL would disable staleness protection for the host until some later message
// happened to carry a parseable timestamp.
require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-3", nil))
messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID)
require.NoError(t, err)
require.Equal(t, "msg-3", messageID)
require.NotNil(t, eventTime, "a nil event time must not clear the recorded baseline")
require.WithinDuration(t, t2, *eventTime, time.Millisecond)
// An empty messageId advances only the timestamp — this is how ReconcileAndroidDevices
// records an out-of-band unenroll, which has no Pub/Sub message of its own.
t3 := t2.Add(time.Hour)
require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "", &t3))
messageID, eventTime, err = ds.GetAndroidPubSubDedupState(testCtx(), hostID)
require.NoError(t, err)
require.Equal(t, "msg-3", messageID, "an empty messageId must not clear the recorded messageId")
require.WithinDuration(t, t3, *eventTime, time.Millisecond)
// Writing the same values again still reports the row as found (clientFoundRows), so it
// must not be mistaken for a missing android_devices row.
require.NoError(t, ds.SetAndroidPubSubDedupState(testCtx(), hostID, "msg-3", &t3))
// Unknown host -> NotFound (both get and set).
_, _, err = ds.GetAndroidPubSubDedupState(testCtx(), 999999)
require.True(t, fleet.IsNotFound(err), "expected NotFound for unknown host, got %v", err)
err = ds.SetAndroidPubSubDedupState(testCtx(), 999999, "msg-x", &t2)
require.True(t, fleet.IsNotFound(err), "set on a missing android_devices row must surface NotFound, got %v", err)
}
func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) {
// Set a non-empty server URL so initial enrolled row has data to clear
appCfg, err := ds.AppConfig(testCtx())
require.NoError(t, err)
appCfg.ServerSettings.ServerURL = "https://mdm.example.com"
require.NoError(t, ds.SaveAppConfig(testCtx(), appCfg))
// Create an Android host (this also upserts an enrolled host_mdm row)
esid := "enterprise-" + uuid.NewString()
h := createAndroidHost(esid)
res, err := ds.NewAndroidHost(testCtx(), h, false)
require.NoError(t, err)
// Sanity check initial host_mdm values
var enrolled int
var serverURL string
var mdmIDIsNull int
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &enrolled, `SELECT enrolled FROM host_mdm WHERE host_id = ?`, res.Host.ID)
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &serverURL, `SELECT server_url FROM host_mdm WHERE host_id = ?`, res.Host.ID)
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &mdmIDIsNull, `SELECT CASE WHEN mdm_id IS NULL THEN 1 ELSE 0 END FROM host_mdm WHERE host_id = ?`, res.Host.ID)
})
require.Equal(t, 1, enrolled)
require.NotEmpty(t, serverURL)
require.Equal(t, 0, mdmIDIsNull)
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending)
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending)
// Insert a certificate template record for this host to verify it gets deleted on unenroll.
err = ds.BulkInsertHostCertificateTemplates(testCtx(), []fleet.HostCertificateTemplate{
{
HostUUID: res.Host.UUID,
CertificateTemplateID: 1,
Status: fleet.CertificateTemplateVerified,
OperationType: fleet.MDMOperationTypeInstall,
Name: "test-cert",
},
})
require.NoError(t, err)
// Perform single-host unenroll
didUnenroll, err := ds.SetAndroidHostUnenrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
require.True(t, didUnenroll)
// Calling unenrolled again returns false
didUnenroll, err = ds.SetAndroidHostUnenrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
require.False(t, didUnenroll)
profileCountForHost := 0
// Validate host_mdm row updated
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &enrolled, `SELECT enrolled FROM host_mdm WHERE host_id = ?`, res.Host.ID)
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &serverURL, `SELECT server_url FROM host_mdm WHERE host_id = ?`, res.Host.ID)
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &mdmIDIsNull, `SELECT CASE WHEN mdm_id IS NULL THEN 1 ELSE 0 END FROM host_mdm WHERE host_id = ?`, res.Host.ID)
})
// Validate profile records deleted
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &profileCountForHost, `SELECT COUNT(*) FROM host_mdm_android_profiles WHERE host_uuid=?`, res.Host.UUID)
})
assert.Equal(t, 0, enrolled)
assert.Equal(t, "", serverURL)
assert.Equal(t, 1, mdmIDIsNull)
assert.Equal(t, 0, profileCountForHost)
// Validate certificate template records deleted
certRecords, err := ds.GetHostCertificateTemplates(testCtx(), res.Host.UUID)
require.NoError(t, err)
assert.Empty(t, certRecords)
}
func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)
// Set a non-empty server URL so initial enrolled row has data to clear
appCfg, err := ds.AppConfig(testCtx())
require.NoError(t, err)
appCfg.ServerSettings.ServerURL = "https://mdm.example.com"
require.NoError(t, ds.SaveAppConfig(testCtx(), appCfg))
// Create 5 android hosts
var androidHostUUIDs []string
for i := 0; i < 5; i++ {
esid := "enterprise-" + uuid.NewString()
h := createAndroidHost(esid)
res, err := ds.NewAndroidHost(testCtx(), h, false)
require.NoError(t, err)
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending)
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending)
// Insert a certificate template record for each host.
err = ds.BulkInsertHostCertificateTemplates(testCtx(), []fleet.HostCertificateTemplate{
{
HostUUID: res.Host.UUID,
CertificateTemplateID: 1,
Status: fleet.CertificateTemplateVerified,
OperationType: fleet.MDMOperationTypeInstall,
Name: "test-cert",
},
})
require.NoError(t, err)
androidHostUUIDs = append(androidHostUUIDs, res.Host.UUID)
}
// Create a macOS host (to verify we don't unenroll non-Android hosts)
macHost, err := ds.NewHost(testCtx(), &fleet.Host{
Hostname: "test-host1-name",
OsqueryHostID: ptr.String("1337"),
NodeKey: ptr.String("1337"),
UUID: "test-uuid-1",
Platform: "darwin",
HardwareSerial: uuid.NewString(),
})
require.NoError(t, err)
nanoEnroll(t, ds, macHost, false)
err = ds.MDMAppleUpsertHost(testCtx(), macHost, false)
require.NoError(t, err)
// Initial sanity check
enrolledCount := 0
androidHostProfileCount := 0
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &enrolledCount, `SELECT COUNT(*) FROM host_mdm WHERE enrolled = 1`)
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &androidHostProfileCount, `SELECT COUNT(*) FROM host_mdm_android_profiles`)
})
assert.Equal(t, 10, androidHostProfileCount)
require.Equal(t, 6, enrolledCount) // 5 android + 1 macOS
// Verify each android host has a certificate template record.
for _, hostUUID := range androidHostUUIDs {
records, err := ds.GetHostCertificateTemplates(testCtx(), hostUUID)
require.NoError(t, err)
require.Len(t, records, 1)
}
err = ds.BulkSetAndroidHostsUnenrolled(testCtx())
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &enrolledCount, `SELECT COUNT(*) FROM host_mdm WHERE enrolled = 1`)
})
require.Equal(t, 1, enrolledCount)
// Validate profile records deleted
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(testCtx(), q, &androidHostProfileCount, `SELECT COUNT(*) FROM host_mdm_android_profiles`)
})
assert.Equal(t, 0, androidHostProfileCount)
// Validate certificate template records deleted for all android hosts
for _, hostUUID := range androidHostUUIDs {
records, err := ds.GetHostCertificateTemplates(testCtx(), hostUUID)
require.NoError(t, err)
assert.Empty(t, records)
}
}
// setupTestApp creates a test Android app in vpp_apps table
func setupTestApp(t *testing.T, ds *Datastore, appID string) {
_, err := ds.writer(testCtx()).ExecContext(testCtx(), `
INSERT INTO vpp_apps (adam_id, platform, bundle_identifier, name, latest_version, icon_url)
VALUES (?, 'android', ?, 'Test App', '1.0', 'http://example.com/icon.png')
`, appID, appID)
require.NoError(t, err)
}
// setupTestTeam creates a test team
func setupTestTeam(t *testing.T, ds *Datastore) uint {
team, err := ds.NewTeam(testCtx(), &fleet.Team{Name: "Test Team"})
require.NoError(t, err)
return team.ID
}
func testInsertAndGetAndroidAppConfiguration(t *testing.T, ds *Datastore) {
appID := "com.example.testapp"
setupTestApp(t, ds, appID)
configuration := json.RawMessage(`{"managedConfiguration": {"key": "value"}}`)
// Insert configuration
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), 0, appID, configuration))
// Get configuration
retrieved, err := ds.GetAndroidAppConfiguration(testCtx(), appID, 0)
require.NoError(t, err)
require.NotNil(t, retrieved)
require.JSONEq(t, string(configuration), string(retrieved))
// test bulk-get configuration
configsByAppID, err := ds.BulkGetAndroidAppConfigurations(testCtx(), []string{appID}, 0)
require.NoError(t, err)
require.Len(t, configsByAppID, 1)
require.Equal(t, string(retrieved), string(configsByAppID[appID]))
// bulk-get configuration returns any known app config, ignores others
configsByAppID, err = ds.BulkGetAndroidAppConfigurations(testCtx(), []string{appID, "no-such-app"}, 0)
require.NoError(t, err)
require.Len(t, configsByAppID, 1)
require.Equal(t, string(retrieved), string(configsByAppID[appID]))
}
func testUpdateAndroidAppConfiguration(t *testing.T, ds *Datastore) {
appID := "com.example.updateapp"
setupTestApp(t, ds, appID)
configuration := json.RawMessage(`{"managedConfiguration": {"key": "value1"}}`)
// Insert initial configuration
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), 0, appID, configuration))
// Update configuration
newConfig := json.RawMessage(`{"managedConfiguration": {"key": "value2"}, "workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`)
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), 0, appID, newConfig))
// Verify update
retrieved, err := ds.GetAndroidAppConfiguration(testCtx(), appID, 0)
require.NoError(t, err)
require.JSONEq(t, string(newConfig), string(retrieved))
}
func testDeleteAndroidAppConfiguration(t *testing.T, ds *Datastore) {
appID := "com.example.deleteapp"
setupTestApp(t, ds, appID)
configuration := json.RawMessage(`{"managedConfiguration": {}}`)
// Insert configuration
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), 0, appID, configuration))
// Verify it exists
_, err := ds.GetAndroidAppConfiguration(testCtx(), appID, 0)
require.NoError(t, err)
// Delete configuration
err = ds.DeleteAndroidAppConfiguration(testCtx(), appID, 0)
require.NoError(t, err)
// Verify it's deleted
_, err = ds.GetAndroidAppConfiguration(testCtx(), appID, 0)
require.Error(t, err)
require.ErrorContains(t, err, "not found")
}
func testGetAndroidAppConfigurationNotFound(t *testing.T, ds *Datastore) {
_, err := ds.GetAndroidAppConfiguration(testCtx(), "nonexistent.app", 0)
require.Error(t, err)
require.ErrorContains(t, err, "not found")
}
func testDeleteAndroidAppConfigurationNotFound(t *testing.T, ds *Datastore) {
err := ds.DeleteAndroidAppConfiguration(testCtx(), "nonexistent.app", 0)
require.Error(t, err)
require.ErrorContains(t, err, "not found")
}
func testAndroidAppConfigurationCascadeDeleteTeam(t *testing.T, ds *Datastore) {
appID := "com.example.teamcascadeapp"
setupTestApp(t, ds, appID)
teamID := setupTestTeam(t, ds)
configuration := json.RawMessage(`{"managedConfiguration": {}}`)
// Insert configuration
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), teamID, appID, configuration))
// Verify it exists
_, err := ds.GetAndroidAppConfiguration(testCtx(), appID, teamID)
require.NoError(t, err)
// Delete the team
err = ds.DeleteTeam(testCtx(), teamID)
require.NoError(t, err)
// Verify configuration is also deleted (CASCADE)
_, err = ds.GetAndroidAppConfiguration(testCtx(), appID, teamID)
require.Error(t, err)
require.ErrorContains(t, err, "not found")
}
func testAndroidAppConfigurationGlobalVsTeam(t *testing.T, ds *Datastore) {
appID := "com.example.globalvsteamapp"
setupTestApp(t, ds, appID)
teamID := setupTestTeam(t, ds)
// Insert global configuration
globalConfiguration := json.RawMessage(`{"managedConfiguration": {"env": "global"}}`)
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), 0, appID, globalConfiguration))
// Insert team configuration
teamConfiguration := json.RawMessage(`{"managedConfiguration": {"env": "team"}}`)
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), teamID, appID, teamConfiguration))
// Verify global configuration
retrievedGlobal, err := ds.GetAndroidAppConfiguration(testCtx(), appID, 0)
require.NoError(t, err)
require.JSONEq(t, `{"managedConfiguration": {"env": "global"}}`, string(retrievedGlobal))
// Verify team configuration
retrievedTeam, err := ds.GetAndroidAppConfiguration(testCtx(), appID, teamID)
require.NoError(t, err)
require.JSONEq(t, `{"managedConfiguration": {"env": "team"}}`, string(retrievedTeam))
}
func testAddDeleteAndroidAppWithConfiguration(t *testing.T, ds *Datastore) {
ctx := context.Background()
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
test.CreateInsertGlobalVPPToken(t, ds)
testConfig := []byte(`{"ManagedConfiguration": {"DisableShareScreen": true, "DisableComputerAudio": true}}`)
// Create android and VPP apps
app1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "android1", BundleIdentifier: "android1",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{AdamID: "something_android_app_1", Platform: fleet.AndroidPlatform},
Configuration: testConfig,
},
}, &team1.ID)
require.NoError(t, err)
app2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_forapple_1", Platform: fleet.IOSPlatform},
Configuration: []byte(`<dict><key>FromIOSTest</key><true/></dict>`),
},
}, &team1.ID)
require.NoError(t, err)
// Get android app without team
meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, app1.TitleID)
require.NoError(t, err)
require.Zero(t, meta.Configuration)
// Get android app and configuration
meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID)
require.NoError(t, err)
require.NotZero(t, meta.VPPAppsTeamsID)
require.NotZero(t, meta.Configuration)
require.Equal(t, "android1", meta.BundleIdentifier)
require.JSONEq(t, string(testConfig), string(meta.Configuration))
// Get ios app
meta2, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, app2.TitleID)
require.NoError(t, err)
require.NotZero(t, meta2.VPPAppsTeamsID)
// Edit android app
newConfig := []byte(`{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`)
app1.VPPAppTeam.Configuration = newConfig
_, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
require.NoError(t, err)
// Check that configuration was changed
meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID)
require.NoError(t, err)
require.NotZero(t, meta.VPPAppsTeamsID)
require.JSONEq(t, string(newConfig), string(meta.Configuration))
// Add invalid configuration
badConfig := []byte(`"-": "-"`)
app1.VPPAppTeam.Configuration = badConfig
_, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
require.Error(t, err)
// Delete app, should delete configuration
require.NoError(t, ds.DeleteVPPAppFromTeam(ctx, &team1.ID, app1.VPPAppID))
_, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID)
require.ErrorContains(t, err, "not found")
_, err = ds.GetAndroidAppConfiguration(ctx, app1.AdamID, team1.ID)
require.ErrorContains(t, err, "not found")
}
func testHasAndroidAppConfigurationChanged(t *testing.T, ds *Datastore) {
ctx := context.Background()
appID := "com.example.testapp"
setupTestApp(t, ds, appID)
configuration := json.RawMessage(`{"managedConfiguration": {"a": 1}}`)
require.NoError(t, ds.updateAndroidAppConfigurationTx(testCtx(), ds.writer(testCtx()), 0, appID, configuration))
cases := []struct {
desc string
newConfig string
compareAppID string
changed bool
}{
{
desc: "empty new config",
newConfig: "",
compareAppID: appID,
changed: true,
},
{
desc: "empty object",
newConfig: "{}",
compareAppID: appID,
changed: true,
},
{
desc: "boolean instead of object",
newConfig: "false",
compareAppID: appID,
changed: true,
},
{
desc: "empty managedConfiguration",
newConfig: `{"managedConfiguration": {}}`,
compareAppID: appID,
changed: true,
},
{
desc: "same config",
newConfig: `{"managedConfiguration": {"a":1}}`,
compareAppID: appID,
changed: false,
},
{
desc: "slightly different config",
newConfig: `{"managedConfiguration": {"a":"b"}}`,
compareAppID: appID,
changed: true,
},
{
desc: "expanded different config",
newConfig: `{"managedConfiguration": {"a":1, "b":2}}`,
compareAppID: appID,
changed: true,
},
{
desc: "very different config",
newConfig: `{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`,
compareAppID: appID,
changed: true,
},
{
desc: "empty compared to non-existing",
newConfig: ``,
compareAppID: "com.no-such.app",
changed: false,
},
{
desc: "some config compared to non-existing",
newConfig: `{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`,
compareAppID: "com.no-such.app",
changed: true,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got, err := ds.HasAndroidAppConfigurationChanged(ctx, c.compareAppID, 0, json.RawMessage(c.newConfig))
require.NoError(t, err)
require.Equal(t, c.changed, got)
})
}
}
func testUpdateTeamIDOnAndroidDevices(t *testing.T, ds *Datastore) {
ctx := testCtx()
test.AddBuiltinLabels(t, ds)
// Create a team.
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-update-test"})
require.NoError(t, err)
// Create two Android hosts with no team.
host1 := createAndroidHost("esid-update-1")
h1, err := ds.NewAndroidHost(ctx, host1, false)
require.NoError(t, err)
host2 := createAndroidHost("esid-update-2")
h2, err := ds.NewAndroidHost(ctx, host2, false)
require.NoError(t, err)
// Verify team_id starts as NULL.
var teamID1 *uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &teamID1, `SELECT team_id FROM android_devices WHERE host_id = ?`, h1.Host.ID)
})
require.Nil(t, teamID1)
// Update both devices to the team.
err = ds.UpdateTeamIDOnAndroidDevices(ctx, []string{h1.Host.UUID, h2.Host.UUID}, &team.ID)
require.NoError(t, err)
// Verify both were updated.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &teamID1, `SELECT team_id FROM android_devices WHERE host_id = ?`, h1.Host.ID)
})
require.NotNil(t, teamID1)
require.Equal(t, team.ID, *teamID1)
var teamID2 *uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &teamID2, `SELECT team_id FROM android_devices WHERE host_id = ?`, h2.Host.ID)
})
require.NotNil(t, teamID2)
require.Equal(t, team.ID, *teamID2)
// Update to no team (nil).
err = ds.UpdateTeamIDOnAndroidDevices(ctx, []string{h1.Host.UUID}, nil)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &teamID1, `SELECT team_id FROM android_devices WHERE host_id = ?`, h1.Host.ID)
})
require.Nil(t, teamID1)
// Empty slice is a no-op.
err = ds.UpdateTeamIDOnAndroidDevices(ctx, []string{}, &team.ID)
require.NoError(t, err)
}
func testGetAndroidDeviceLastTeamID(t *testing.T, ds *Datastore) {
ctx := testCtx()
test.AddBuiltinLabels(t, ds)
// Create a team and a host on that team.
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-last-id-test"})
require.NoError(t, err)
host := createAndroidHost("esid-last-team")
host.Host.TeamID = &team.ID
h, err := ds.NewAndroidHost(ctx, host, false)
require.NoError(t, err)
// NewAndroidHost syncs team_id, so it should be set.
gotTeamID, found, err := ds.GetAndroidDeviceLastTeamID(ctx, "esid-last-team")
require.NoError(t, err)
require.True(t, found)
require.NotNil(t, gotTeamID)
require.Equal(t, team.ID, *gotTeamID)
// Delete the host — android_devices row should survive.
err = ds.DeleteHosts(ctx, []uint{h.Host.ID})
require.NoError(t, err)
// Should still find the prior team.
gotTeamID, found, err = ds.GetAndroidDeviceLastTeamID(ctx, "esid-last-team")
require.NoError(t, err)
require.True(t, found)
require.NotNil(t, gotTeamID)
require.Equal(t, team.ID, *gotTeamID)
// Non-existent device returns not found.
_, found, err = ds.GetAndroidDeviceLastTeamID(ctx, "no-such-device")
require.NoError(t, err)
require.False(t, found)
}