package mysql import ( "context" "encoding/json" "fmt" "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}, {"UpdateAndroidHost", testUpdateAndroidHost}, {"AndroidMDMStats", testAndroidMDMStats}, {"AndroidHostStorageData", testAndroidHostStorageData}, {"NewMDMAndroidConfigProfile", testNewMDMAndroidConfigProfile}, {"GetMDMAndroidConfigProfile", testGetMDMAndroidConfigProfile}, {"DeleteMDMAndroidConfigProfile", testDeleteMDMAndroidConfigProfile}, {"GetMDMAndroidProfilesSummary", testMDMAndroidProfilesSummary}, {"ListMDMAndroidProfilesToSend", testListMDMAndroidProfilesToSend}, {"ListMDMAndroidProfilesToSend_WithExcludeAny", testListMDMAndroidProfilesToSendWithExcludeAny}, {"GetMDMAndroidProfilesContents", testGetMDMAndroidProfilesContents}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles2}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles3}, {"GetHostMDMAndroidProfiles", testGetHostMDMAndroidProfiles}, {"GetAndroidPolicyRequestByUUID", testGetAndroidPolicyRequestByUUID}, {"ListHostMDMAndroidProfilesPendingInstallWithVersion", testListHostMDMAndroidProfilesPendingInstallWithVersion}, {"BulkDeleteMDMAndroidHostProfiles", testBulkDeleteMDMAndroidHostProfiles}, {"BatchSetMDMAndroidProfiles_Associations", testBatchSetMDMAndroidProfiles_Associations}, {"NewAndroidHostWithIdP", testNewAndroidHostWithIdP}, {"AndroidBYODDetection", testAndroidBYODDetection}, {"SetAndroidHostUnenrolled", testSetAndroidHostUnenrolled}, {"BulkSetAndroidHostsUnenrolled", testBulkSetAndroidHostsUnenrolled}, {"InsertAndGetAndroidAppConfiguration", testInsertAndGetAndroidAppConfiguration}, {"UpdateAndroidAppConfiguration", testUpdateAndroidAppConfiguration}, {"DeleteAndroidAppConfiguration", testDeleteAndroidAppConfiguration}, {"GetAndroidAppConfiguration_NotFound", testGetAndroidAppConfigurationNotFound}, {"DeleteAndroidAppConfiguration_NotFound", testDeleteAndroidAppConfigurationNotFound}, {"AndroidAppConfiguration_CascadeDeleteTeam", testAndroidAppConfigurationCascadeDeleteTeam}, {"AndroidAppConfiguration_GlobalVsTeam", testAndroidAppConfigurationGlobalVsTeam}, {"AddDeleteAndroidAppWithConfiguration", testAddDeleteAndroidAppWithConfiguration}, {"HasAndroidAppConfigurationChanged", testHasAndroidAppConfigurationChanged}, } 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) 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) 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) require.NoError(t, err) lbls, err = ds.ListLabelsForHost(testCtx(), result.Host.ID) require.NoError(t, err) require.Empty(t, lbls) } 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) require.NoError(t, err) assert.NotZero(t, result.Host.ID) assert.NotZero(t, result.Device.ID) // Dummy update err = ds.UpdateAndroidHost(testCtx(), result, 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) 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) 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) 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) 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") }) } 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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 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) 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) 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) 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) require.NoError(t, err) require.NotNil(t, profile1) profile2 := androidProfileForTest("profile2") profile2, err = ds.NewMDMAndroidConfigProfile(ctx, *profile2) require.NoError(t, err) require.NotNil(t, profile2) profile3 := androidProfileForTest("profile3") profile3, err = ds.NewMDMAndroidConfigProfile(ctx, *profile3) 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 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) require.NoError(t, err) hosts[i] = newHost.Host } // without any profile, should return empty profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx) 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")) require.NoError(t, err) p2, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-2")) require.NoError(t, err) tmP3 := androidProfileForTest("team-1") tmP3.TeamID = &tm.ID p3, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP3) require.NoError(t, err) // both no-team profiles should be applicable to both hosts profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p1.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p1.Name}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p2.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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)) require.NoError(t, err) // no change, host is not a member of both labels profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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)) require.NoError(t, err) // no change, host 0 not a member yet profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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)) require.NoError(t, err) // no change, label membership was not updated after labels created profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, {ProfileUUID: p6.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p6.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, profs) // add another host in team androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", 2)) newHost, err := ds.NewAndroidHost(ctx, androidHost) 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: p3.Name}, }, 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) VALUES (?, ?, ?, ?, ?, ?)`, hosts[2].UUID, p3.ProfileUUID, p3.Name, 1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) return err }) // host 2 is not included in the results as it has p3 installed profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p3.Name}, }, 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) require.NoError(t, err) require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: p3.Name}, }, toRemoveProfs) require.Len(t, profs, 4) require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ {ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p5.Name}, }, 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) 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) require.NoError(t, err) hosts[i] = newHost.Host } // without any profile, should return empty profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx) 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)) require.NoError(t, err) // Manual exclude-any label only p2, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-2", lblExclAny2)) require.NoError(t, err) // Both manual and dynamic label exclusion p3, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-3", lblExclAny1, lblExclAny2)) require.NoError(t, err) // p2 becomes immediately applicable because it only has a manual label profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, }, 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) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name}, }, 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) require.NoError(t, err) // Manual exclude-any label only p5, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP5) require.NoError(t, err) // Both manual and dynamic label exclusion p6, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP6) require.NoError(t, err) // p5 becomes immediately applicable to host 1 because it only has a manual label profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, }, 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() hosts[1].TeamID = &tm.ID err = ds.UpdateHost(ctx, hosts[1]) require.NoError(t, err) profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, {ProfileUUID: p6.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p6.Name}, }, 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) 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}, {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p4.Name}, {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, {ProfileUUID: p6.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p6.Name}, }, 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) 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}, }, 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) require.NoError(t, err) p2, err = ds.NewMDMAndroidConfigProfile(ctx, *p2) require.NoError(t, err) p3, err = ds.NewMDMAndroidConfigProfile(ctx, *p3) 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) 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) require.NoError(t, err) profiles[i] = p } err = ds.BulkUpsertMDMAndroidHostProfiles(ctx, nil) require.NoError(t, err) ds.testUpsertMDMDesiredProfilesBatchSize = batchSize t.Cleanup(func() { ds.testUpsertMDMDesiredProfilesBatchSize = 0 }) hostProfiles, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[1].Name}, {ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[0].Name}, {ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[1].Name}, {ProfileUUID: profiles[2].ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: profiles[2].Name}, }, 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), }, { HostUUID: hosts[0].UUID, ProfileUUID: profiles[1].ProfileUUID, ProfileName: profiles[1].Name, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending, IncludedInPolicyVersion: ptr.Int(1), }, { HostUUID: hosts[1].UUID, ProfileUUID: profiles[1].ProfileUUID, ProfileName: profiles[1].Name, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryFailed, IncludedInPolicyVersion: ptr.Int(1), }, }) require.NoError(t, err) hostProfiles, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: profiles[1].Name}, {ProfileUUID: profiles[2].ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: profiles[2].Name}, }, 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), }, { HostUUID: hosts[1].UUID, ProfileUUID: profiles[0].ProfileUUID, ProfileName: profiles[0].Name, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending, IncludedInPolicyVersion: ptr.Int(1), }, { HostUUID: hosts[2].UUID, ProfileUUID: profiles[2].ProfileUUID, ProfileName: profiles[2].Name, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending, IncludedInPolicyVersion: ptr.Int(1), }, }) require.NoError(t, err) hostProfiles, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) 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}, {ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[1].Name}, // 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) require.NoError(t, err) require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ {ProfileUUID: profiles[2].ProfileUUID, HostUUID: hosts[2].UUID, ProfileName: profiles[2].Name}, }, toRemoveProfs) require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ {ProfileUUID: profiles[0].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[0].Name}, {ProfileUUID: profiles[1].ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: profiles[1].Name}, }, 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 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) 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[0].ProfileUUID, ProfileName: profiles[0].Name, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryFailed, IncludedInPolicyVersion: policyVersion, }, { 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.ListHostMDMAndroidProfilesPendingInstallWithVersion(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.ListHostMDMAndroidProfilesPendingInstallWithVersion(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.ListHostMDMAndroidProfilesPendingInstallWithVersion(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.ListHostMDMAndroidProfilesPendingInstallWithVersion(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) }) } 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) 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) 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) 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 without UUID", func(t *testing.T) { const enterpriseID = "test-enterprise-id-company" host := createAndroidHost(enterpriseID) // Override UUID to be empty to simulate company-owned device host.Host.UUID = "" result, err := ds.NewAndroidHost(ctx, host) 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 without UUID 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) 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) // 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 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) 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) // 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) } 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 for i := 0; i < 5; i++ { esid := "enterprise-" + uuid.NewString() h := createAndroidHost(esid) res, err := ds.NewAndroidHost(testCtx(), h) require.NoError(t, err) upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending) upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending) } // 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 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) } // 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 := json.RawMessage(`{"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: json.RawMessage(`{"ManagedConfiguration": {"ios app shouldn't have configuration": true}}`), }}, &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.Equal(t, testConfig, 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 := json.RawMessage(`{"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.Equal(t, newConfig, meta.Configuration) // Add invalid configuration badConfig := json.RawMessage(`"-": "-"`) 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) }) } }