<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #48340 Windows only. The fix is to use a rollup status table instead of recalculating the host profile summary on demand. Verified the fix in load test with 100k Windows MDM hosts. Note that this does not improve the host details page filtered by OS settings, which will be handled by the follow up https://github.com/fleetdm/fleet/issues/48996 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved Windows fleet configuration profile status summaries to avoid timeouts on large fleets. * Kept per-host Windows profile statuses accurate after profile updates, resends, certificate changes, cleanup, unenrollment, and host deletion. * Added automatic reconciliation to correct stale or orphaned status data. * **Data Integrity** * Improved Windows profile status reporting, including profile and BitLocker summaries, for more reliable results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
5918 lines
218 KiB
Go
5918 lines
218 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"crypto/x509"
|
||
"crypto/x509/pkix"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
|
||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
|
||
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||
"github.com/fleetdm/fleet/v4/server/service"
|
||
"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 TestMDMShared(t *testing.T) {
|
||
ds := CreateMySQLDS(t)
|
||
TruncateTables(t, ds)
|
||
|
||
cases := []struct {
|
||
name string
|
||
fn func(t *testing.T, ds *Datastore)
|
||
}{
|
||
{"TestMDMCommands", testMDMCommands},
|
||
{"TestListMDMCommandsWithTeamFilter", testListMDMCommandsWithTeamFilter},
|
||
{"TestListMDMCommandsOrderKeys", testListMDMCommandsOrderKeys},
|
||
{"TestListMDMAppleCommandsOrderKeys", testListMDMAppleCommandsOrderKeys},
|
||
{"TestListMDMCommandsRequiresPerPage", testListMDMCommandsRequiresPerPage},
|
||
{"TestListMDMCommandsPagination", testListMDMCommandsPagination},
|
||
{"TestBatchSetMDMProfiles", testBatchSetMDMProfiles},
|
||
{"TestBatchSetMDMProfilesSoftwareUpdateTracking", testBatchSetMDMProfilesSoftwareUpdateTracking},
|
||
{"TestListMDMConfigProfiles", testListMDMConfigProfiles},
|
||
{"TestGetHostMDMProfilesExpectedForVerification", testGetHostMDMProfilesExpectedForVerification},
|
||
{"TestBatchSetProfileLabelAssociations", testBatchSetProfileLabelAssociations},
|
||
{"TestMDMEULA", testMDMEULA},
|
||
{"TestGetHostCertAssociationsToExpire", testSCEPRenewalHelpers},
|
||
{"TestSCEPRenewalHelpers", testSCEPRenewalHelpers},
|
||
{"TestMDMProfilesSummaryAndHostFilters", testMDMProfilesSummaryAndHostFilters},
|
||
{"TestIsHostConnectedToFleetMDM", testIsHostConnectedToFleetMDM},
|
||
{"TestAreHostsConnectedToFleetMDM", testAreHostsConnectedToFleetMDM},
|
||
{"TestBulkSetPendingMDMHostProfilesExcludeAny", testBulkSetPendingMDMHostProfilesExcludeAny},
|
||
{"TestBatchResendProfileToHosts", testBatchResendProfileToHosts},
|
||
{"TestGetMDMConfigProfileStatus", testGetMDMConfigProfileStatus},
|
||
{"TestDeleteMDMProfilesCancelsInstalls", testDeleteMDMProfilesCancelsInstalls},
|
||
{"TestDeleteTeamCancelsWindowsProfileInstalls", testDeleteTeamCancelsWindowsProfileInstalls},
|
||
{"TestBulkSetPendingDefersWindowsReconciliation", testBulkSetPendingDefersWindowsReconciliation},
|
||
{"TestCleanUpMDMManagedCertificates", testCleanUpMDMManagedCertificates},
|
||
{"TestEnqueueCommandWithName", testEnqueueCommandWithName},
|
||
{"TestProfileHasACMEPayloadForCommand", testProfileHasACMEPayloadForCommand},
|
||
{"TestOktaCACleanupTargetForInstallCommand", testOktaCACleanupTargetForInstallCommand},
|
||
{"TestRenewMDMManagedCertificatesNullType", testRenewMDMManagedCertificatesNullType},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
defer TruncateTables(t, ds)
|
||
c.fn(t, ds)
|
||
})
|
||
}
|
||
}
|
||
|
||
func testMDMCommands(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
// no commands or devices enrolled => no results
|
||
cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}})
|
||
require.NoError(t, err)
|
||
require.Empty(t, cmds)
|
||
|
||
// enroll a windows device
|
||
windowsH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "test-host", // ambiguous hostname shared with macOS host
|
||
OsqueryHostID: ptr.String("osquery-windows"),
|
||
NodeKey: ptr.String("node-key-windows"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "windows",
|
||
HardwareSerial: "123456",
|
||
})
|
||
require.NoError(t, err)
|
||
windowsEnrollment := &fleet.MDMWindowsEnrolledDevice{
|
||
MDMDeviceID: uuid.New().String(),
|
||
MDMHardwareID: uuid.New().String() + uuid.New().String(),
|
||
MDMDeviceState: uuid.New().String(),
|
||
MDMDeviceType: "CIMClient_Windows",
|
||
MDMDeviceName: "DESKTOP-1C3ARC1",
|
||
MDMEnrollType: "ProgrammaticEnrollment",
|
||
MDMEnrollUserID: "",
|
||
MDMEnrollProtoVersion: "5.0",
|
||
MDMEnrollClientVersion: "10.0.19045.2965",
|
||
MDMNotInOOBE: false,
|
||
HostUUID: windowsH.UUID,
|
||
}
|
||
err = ds.MDMWindowsInsertEnrolledDevice(ctx, windowsEnrollment)
|
||
require.NoError(t, err)
|
||
windowsEnrollment.ID = mdmWindowsEnrollmentIDByHardwareID(ctx, t, ds, windowsEnrollment.MDMHardwareID)
|
||
_, err = ds.UpdateMDMWindowsEnrollmentsHostUUID(
|
||
ctx,
|
||
windowsEnrollment.HostUUID,
|
||
windowsEnrollment.MDMDeviceID,
|
||
)
|
||
require.NoError(t, err)
|
||
windowsEnrollment, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID(
|
||
ctx,
|
||
windowsEnrollment.MDMDeviceID,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
// enroll a macOS device
|
||
macH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "test-host", // ambiguous hostname shared with windows host
|
||
OsqueryHostID: ptr.String("osquery-macos"),
|
||
NodeKey: ptr.String("node-key-macos"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "654321",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, macH, false)
|
||
|
||
// no commands => no results
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Empty(t, cmds)
|
||
|
||
// insert a windows command
|
||
winCmd := &fleet.MDMWindowsCommand{
|
||
CommandUUID: uuid.NewString(),
|
||
RawCommand: []byte("<Exec></Exec>"),
|
||
TargetLocURI: "./test/uri",
|
||
}
|
||
err = ds.MDMWindowsInsertCommandForHosts(ctx, []string{windowsEnrollment.MDMDeviceID}, winCmd)
|
||
require.NoError(t, err)
|
||
|
||
// we get one result
|
||
cmds, total, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
require.Equal(t, winCmd.CommandUUID, cmds[0].CommandUUID)
|
||
require.Equal(t, winCmd.TargetLocURI, cmds[0].RequestType)
|
||
require.Equal(t, "101", cmds[0].Status)
|
||
require.Nil(t, total)
|
||
|
||
appleCmdUUID := uuid.New().String()
|
||
appleCmd := createRawAppleCmd("ProfileList", appleCmdUUID)
|
||
commander, appleCommanderStorage := createMDMAppleCommanderAndStorage(t, ds)
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, appleCmd)
|
||
require.NoError(t, err)
|
||
|
||
// we get both commands
|
||
cmds, total, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "hostname", PerPage: 100},
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 2)
|
||
require.Equal(t, appleCmdUUID, cmds[0].CommandUUID)
|
||
require.Equal(t, "ProfileList", cmds[0].RequestType)
|
||
require.Equal(t, "Pending", cmds[0].Status)
|
||
require.Equal(t, winCmd.CommandUUID, cmds[1].CommandUUID)
|
||
require.Equal(t, winCmd.TargetLocURI, cmds[1].RequestType)
|
||
require.Equal(t, "101", cmds[1].Status)
|
||
require.Nil(t, total)
|
||
|
||
// store results for both commands
|
||
err = appleCommanderStorage.StoreCommandReport(&mdm.Request{
|
||
EnrollID: &mdm.EnrollID{ID: macH.UUID},
|
||
Context: ctx,
|
||
}, &mdm.CommandResults{
|
||
CommandUUID: appleCmdUUID,
|
||
Status: "Acknowledged",
|
||
Raw: []byte(appleCmd),
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
_, err = ds.MDMWindowsSaveResponse(ctx, windowsEnrollment, fleet.EnrichedSyncML{
|
||
SyncML: &fleet.SyncML{
|
||
Raw: []byte("<xml></xml>"),
|
||
},
|
||
CmdRefUUIDToStatus: map[string]fleet.SyncMLCmd{winCmd.CommandUUID: {
|
||
Data: ptr.String("200"),
|
||
}},
|
||
CmdRefUUIDs: []string{winCmd.CommandUUID},
|
||
}, []string{})
|
||
require.NoError(t, err)
|
||
|
||
// we get both commands
|
||
cmds, total, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "hostname", PerPage: 100},
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 2)
|
||
require.Equal(t, appleCmdUUID, cmds[0].CommandUUID)
|
||
require.Equal(t, "ProfileList", cmds[0].RequestType)
|
||
require.Equal(t, "Acknowledged", cmds[0].Status)
|
||
require.Equal(t, winCmd.CommandUUID, cmds[1].CommandUUID)
|
||
require.Equal(t, winCmd.TargetLocURI, cmds[1].RequestType)
|
||
require.Equal(t, "200", cmds[1].Status)
|
||
require.Nil(t, total)
|
||
|
||
// add more windows commands
|
||
winCmd2 := &fleet.MDMWindowsCommand{
|
||
CommandUUID: uuid.NewString(),
|
||
RawCommand: []byte("<Exec></Exec>"),
|
||
TargetLocURI: "./test/uri2",
|
||
}
|
||
winCmd3 := &fleet.MDMWindowsCommand{
|
||
CommandUUID: uuid.NewString(),
|
||
RawCommand: []byte("<Exec></Exec>"),
|
||
TargetLocURI: "./test/uri3",
|
||
}
|
||
err = ds.MDMWindowsInsertCommandForHosts(ctx, []string{windowsEnrollment.MDMDeviceID}, winCmd2)
|
||
require.NoError(t, err)
|
||
err = ds.MDMWindowsInsertCommandForHosts(ctx, []string{windowsEnrollment.MDMDeviceID}, winCmd3)
|
||
require.NoError(t, err)
|
||
|
||
// add more macos commands
|
||
appleCmdUUID2 := uuid.New().String()
|
||
appleCmd2 := createRawAppleCmd("InstallProfile", appleCmdUUID2)
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, appleCmd2)
|
||
require.NoError(t, err)
|
||
|
||
appleCmdUUID3 := uuid.New().String()
|
||
appleCmd3 := createRawAppleCmd("RemoveProfile", appleCmdUUID3)
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, appleCmd3)
|
||
require.NoError(t, err)
|
||
|
||
// non-existent host identifier
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
HostIdentifier: "non-existent",
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 0)
|
||
|
||
// non-existent request type
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
RequestType: "non-existent",
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 0)
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
identifier string
|
||
commandStatus *fleet.MDMCommandStatusFilter
|
||
expected []string
|
||
requestType string
|
||
}{
|
||
{
|
||
name: "windows host by hostname ambiguous with macOS host",
|
||
identifier: windowsH.Hostname,
|
||
expected: []string{
|
||
winCmd.CommandUUID, winCmd2.CommandUUID, winCmd3.CommandUUID,
|
||
appleCmdUUID, appleCmdUUID2, appleCmdUUID3,
|
||
},
|
||
},
|
||
{
|
||
name: "windows host by UUID",
|
||
identifier: windowsH.UUID,
|
||
expected: []string{winCmd.CommandUUID, winCmd2.CommandUUID, winCmd3.CommandUUID},
|
||
},
|
||
{
|
||
name: "windows host by UUID, filter by request type",
|
||
identifier: windowsH.UUID,
|
||
expected: []string{winCmd2.CommandUUID},
|
||
requestType: "./test/uri2",
|
||
},
|
||
{
|
||
name: "windows host by hardware serial",
|
||
identifier: windowsH.HardwareSerial,
|
||
expected: []string{winCmd.CommandUUID, winCmd2.CommandUUID, winCmd3.CommandUUID},
|
||
},
|
||
{
|
||
name: "macOS host by hostname ambiguous with windows host",
|
||
identifier: macH.Hostname,
|
||
expected: []string{
|
||
appleCmdUUID, appleCmdUUID2, appleCmdUUID3,
|
||
winCmd.CommandUUID, winCmd2.CommandUUID, winCmd3.CommandUUID,
|
||
},
|
||
},
|
||
{
|
||
name: "macOS host by UUID",
|
||
identifier: macH.UUID,
|
||
commandStatus: new(fleet.MDMCommandStatusFilterPending),
|
||
expected: []string{appleCmdUUID2, appleCmdUUID3},
|
||
},
|
||
{
|
||
name: "macOS host by hardware serial",
|
||
identifier: macH.HardwareSerial,
|
||
expected: []string{appleCmdUUID, appleCmdUUID2, appleCmdUUID3},
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
commandStatuses := []fleet.MDMCommandStatusFilter{}
|
||
if tc.commandStatus != nil {
|
||
commandStatuses = append(commandStatuses, *tc.commandStatus)
|
||
}
|
||
cmds, total, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
HostIdentifier: tc.identifier,
|
||
CommandStatuses: commandStatuses,
|
||
RequestType: tc.requestType,
|
||
},
|
||
},
|
||
)
|
||
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, len(tc.expected))
|
||
var got []string
|
||
for _, cmd := range cmds {
|
||
got = append(got, cmd.CommandUUID)
|
||
}
|
||
require.ElementsMatch(t, tc.expected, got)
|
||
|
||
if tc.commandStatus != nil && *tc.commandStatus == fleet.MDMCommandStatusFilterPending {
|
||
require.Equal(t, int64(len(tc.expected)), *total)
|
||
} else {
|
||
require.Nil(t, total)
|
||
}
|
||
})
|
||
}
|
||
|
||
// add macos host
|
||
macH2, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-2",
|
||
OsqueryHostID: ptr.String("osquery-macos-2"),
|
||
NodeKey: ptr.String("node-key-macos-2"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "654322",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, macH2, false)
|
||
|
||
// add more macos commands
|
||
appleCmdUUID4 := uuid.New().String()
|
||
appleCmd4 := createRawAppleCmd("InstallProfile", appleCmdUUID4)
|
||
err = commander.EnqueueCommand(ctx, []string{macH2.UUID}, appleCmd4)
|
||
require.NoError(t, err)
|
||
|
||
// filter by request_type
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "hostname", OrderDirection: fleet.OrderAscending, PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
RequestType: "InstallProfile",
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 2)
|
||
require.Equal(t, appleCmdUUID4, cmds[0].CommandUUID)
|
||
require.Equal(t, appleCmdUUID2, cmds[1].CommandUUID)
|
||
|
||
// filter by request_type and host_identifier
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
RequestType: "InstallProfile",
|
||
HostIdentifier: macH.UUID,
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
require.Equal(t, appleCmdUUID2, cmds[0].CommandUUID)
|
||
|
||
// filter by command status for windows host
|
||
cmds, total, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
HostIdentifier: "123456",
|
||
CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterPending},
|
||
},
|
||
},
|
||
)
|
||
require.Error(t, err)
|
||
require.ErrorContains(t, err, `Currently, "command_status" filter is only available for macOS, iOS, and iPadOS hosts.`)
|
||
require.Nil(t, cmds)
|
||
require.Nil(t, total)
|
||
|
||
failedAppleCmdUUID := uuid.New().String()
|
||
appleCmd = createRawAppleCmd("ProfileList", failedAppleCmdUUID)
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, appleCmd)
|
||
require.NoError(t, err)
|
||
|
||
// store failed result for the command
|
||
err = appleCommanderStorage.StoreCommandReport(&mdm.Request{
|
||
EnrollID: &mdm.EnrollID{ID: macH.UUID},
|
||
Context: ctx,
|
||
}, &mdm.CommandResults{
|
||
CommandUUID: failedAppleCmdUUID,
|
||
Status: "Error",
|
||
Raw: []byte(appleCmd),
|
||
})
|
||
require.NoError(t, err)
|
||
// Update the timestamp to ensure there is enough difference for ordering to have it always come first
|
||
// using default list options
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(
|
||
ctx,
|
||
`UPDATE nano_command_results SET updated_at = updated_at + INTERVAL 1 SECOND WHERE command_uuid = ?`,
|
||
failedAppleCmdUUID,
|
||
)
|
||
return err
|
||
})
|
||
|
||
cmds, total, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: fleet.MDMCommandFilters{
|
||
HostIdentifier: macH.UUID,
|
||
CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterRan, fleet.MDMCommandStatusFilterFailed},
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Nil(t, total)
|
||
require.Len(t, cmds, 2)
|
||
var got []string
|
||
for _, cmd := range cmds {
|
||
got = append(got, cmd.CommandUUID)
|
||
}
|
||
require.Equal(t, []string{failedAppleCmdUUID, appleCmdUUID}, got)
|
||
|
||
// pagination and pagination meta data
|
||
cmds, _, meta, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{Page: 0, PerPage: 1, IncludeMetadata: true},
|
||
Filters: fleet.MDMCommandFilters{
|
||
HostIdentifier: macH.UUID,
|
||
CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterRan, fleet.MDMCommandStatusFilterFailed},
|
||
},
|
||
},
|
||
)
|
||
require.Len(t, cmds, 1)
|
||
require.NoError(t, err)
|
||
require.Equal(t, true, meta.HasNextResults)
|
||
require.Equal(t, false, meta.HasPreviousResults)
|
||
|
||
// Cursor pagination on the host-scoped path. Regression test for
|
||
// https://github.com/fleetdm/fleet/issues/44422:
|
||
// "Error 1052 (23000): Column '<col>' in where clause is ambiguous".
|
||
// Covers all three Path A SQL shapes: Apple-only, Windows-only, and
|
||
// multi-platform (host_identifier resolves to both an Apple and a
|
||
// Windows host via the shared hostname "test-host").
|
||
t.Run("cursor_pagination_44422", func(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
hostIdentifier string
|
||
wantPlatforms []string
|
||
}{
|
||
{"apple_only", macH.UUID, []string{"darwin"}},
|
||
{"multi_platform", "test-host", []string{"darwin", "windows"}},
|
||
{"windows_only", windowsH.UUID, []string{"windows"}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
filters := fleet.MDMCommandFilters{HostIdentifier: tc.hostIdentifier}
|
||
|
||
// Sanity: confirm the dispatch reaches the expected branch(es)
|
||
// and there is data to paginate over.
|
||
all, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{PerPage: 100},
|
||
Filters: filters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, all)
|
||
platforms := map[string]struct{}{}
|
||
for _, c := range all {
|
||
switch c.HostUUID {
|
||
case macH.UUID:
|
||
platforms["darwin"] = struct{}{}
|
||
case windowsH.UUID:
|
||
platforms["windows"] = struct{}{}
|
||
}
|
||
}
|
||
for _, p := range tc.wantPlatforms {
|
||
_, ok := platforms[p]
|
||
require.True(t, ok, "expected commands from platform %s", p)
|
||
}
|
||
|
||
// Cursor on updated_at — `updated_at` is exposed by multiple
|
||
// inner-FROM tables on every Path A branch, so this would fail
|
||
// with "Column 'updated_at' ... is ambiguous" without the wrap.
|
||
page1, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 1,
|
||
OrderKey: "updated_at",
|
||
OrderDirection: fleet.OrderDescending,
|
||
},
|
||
Filters: filters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, page1, 1)
|
||
|
||
page2, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 1,
|
||
OrderKey: "updated_at",
|
||
OrderDirection: fleet.OrderDescending,
|
||
After: page1[0].UpdatedAt.Format(time.RFC3339Nano),
|
||
},
|
||
Filters: filters,
|
||
},
|
||
)
|
||
// require.NoError is the regression guard. The cursor predicate
|
||
// is strict (`updated_at < ?`, no tiebreaker), so page2 may be
|
||
// empty when adjacent commands share an updated_at second — that
|
||
// is expected, not a regression. Only assert distinctness when
|
||
// a row did come back.
|
||
require.NoError(t, err)
|
||
if len(page2) > 0 {
|
||
require.NotEqual(t, page1[0].CommandUUID, page2[0].CommandUUID)
|
||
}
|
||
|
||
// Cursor on command_uuid — separate ambiguity manifestation;
|
||
// `command_uuid` is the join column on every inner table.
|
||
first, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 1,
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
},
|
||
Filters: filters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, first, 1)
|
||
|
||
next, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 1,
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
After: first[0].CommandUUID,
|
||
},
|
||
Filters: filters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, next)
|
||
require.NotEqual(t, first[0].CommandUUID, next[0].CommandUUID)
|
||
|
||
// Cursor on hostname. All hosts in this test share hostname
|
||
// "test-host", and the helper's cursor predicate is a strict
|
||
// `hostname > 'test-host'` (no tiebreaker), so the result is
|
||
// expected to be empty. The bug would surface as a SQL error
|
||
// before the empty check.
|
||
afterHostname, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 5,
|
||
OrderKey: "hostname",
|
||
OrderDirection: fleet.OrderAscending,
|
||
After: "test-host",
|
||
},
|
||
Filters: filters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Empty(t, afterHostname)
|
||
|
||
// Pending-only count + cursor
|
||
if tc.name == "apple_only" {
|
||
pendingFilters := fleet.MDMCommandFilters{
|
||
HostIdentifier: tc.hostIdentifier,
|
||
CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterPending},
|
||
}
|
||
// Establish the unfiltered pending baseline so the post-cursor
|
||
// assertions don't depend on UUID lexicographic ordering.
|
||
allPending, totalPending, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 100,
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
IncludeMetadata: true,
|
||
},
|
||
Filters: pendingFilters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, totalPending)
|
||
require.Equal(t, int64(len(allPending)), *totalPending)
|
||
require.Greater(t, len(allPending), 1, "test setup expects multiple pending Apple commands")
|
||
|
||
// Drive the cursor from a known pending UUID so the post-cursor
|
||
// expectation is deterministic.
|
||
cursorUUID := allPending[0].CommandUUID
|
||
expectedAfter := allPending[1:]
|
||
|
||
afterPending, totalAfter, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
PerPage: 100,
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
After: cursorUUID,
|
||
IncludeMetadata: true,
|
||
},
|
||
Filters: pendingFilters,
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
// total comes from countStmt which has no cursor, so it
|
||
// must report the full pending count regardless of After.
|
||
require.NotNil(t, totalAfter)
|
||
require.Equal(t, *totalPending, *totalAfter)
|
||
require.Len(t, afterPending, len(expectedAfter))
|
||
gotUUIDs := make([]string, len(afterPending))
|
||
for i, c := range afterPending {
|
||
gotUUIDs[i] = c.CommandUUID
|
||
}
|
||
expectedUUIDs := make([]string, len(expectedAfter))
|
||
for i, c := range expectedAfter {
|
||
expectedUUIDs[i] = c.CommandUUID
|
||
}
|
||
require.Equal(t, expectedUUIDs, gotUUIDs)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// testListMDMCommandsWithTeamFilter tests listing MDM commands with team filters
|
||
// This specifically tests the regression where h.team_id was referenced but the query uses combined_commands alias
|
||
func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
// Create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test-team-mdm-commands"})
|
||
require.NoError(t, err)
|
||
|
||
// Create a host in the team
|
||
teamHost, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "team-host",
|
||
OsqueryHostID: ptr.String("osquery-team"),
|
||
NodeKey: ptr.String("node-key-team"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "789012",
|
||
TeamID: &team.ID,
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, teamHost, false)
|
||
|
||
// Create a command for the team host
|
||
teamCmdUUID := uuid.New().String()
|
||
teamCmd := createRawAppleCmd("ProfileList", teamCmdUUID)
|
||
commander, _ := createMDMAppleCommanderAndStorage(t, ds)
|
||
err = commander.EnqueueCommand(ctx, []string{teamHost.UUID}, teamCmd)
|
||
require.NoError(t, err)
|
||
|
||
// Create a user with team access
|
||
teamUser := &fleet.User{
|
||
ID: 999,
|
||
Email: "team-user@example.com",
|
||
Teams: []fleet.UserTeam{{Team: *team, Role: fleet.RoleMaintainer}},
|
||
}
|
||
|
||
// List commands with team filter (no host identifier) - this would trigger the bug hit by customer
|
||
cmds, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: teamUser},
|
||
&fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
require.Equal(t, teamCmdUUID, cmds[0].CommandUUID)
|
||
|
||
// Test with a specific team filter
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: teamUser, TeamID: &team.ID},
|
||
&fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
require.Equal(t, teamCmdUUID, cmds[0].CommandUUID)
|
||
|
||
// Create a host in no team
|
||
globalHost, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "global-host",
|
||
OsqueryHostID: ptr.String("osquery-global"),
|
||
NodeKey: ptr.String("node-key-global"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "789013",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, globalHost, false)
|
||
|
||
// Create a command for the global host
|
||
globalCmdUUID := uuid.New().String()
|
||
globalCmd := createRawAppleCmd("ProfileList", globalCmdUUID)
|
||
err = commander.EnqueueCommand(ctx, []string{globalHost.UUID}, globalCmd)
|
||
require.NoError(t, err)
|
||
|
||
// Team user should only see team command
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: teamUser},
|
||
&fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
require.Equal(t, teamCmdUUID, cmds[0].CommandUUID)
|
||
|
||
// Test with admin user (should see all commands)
|
||
adminUser := test.UserAdmin
|
||
cmds, _, _, err = ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: adminUser},
|
||
&fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 2)
|
||
var got []string
|
||
for _, cmd := range cmds {
|
||
got = append(got, cmd.CommandUUID)
|
||
}
|
||
require.ElementsMatch(t, []string{teamCmdUUID, globalCmdUUID}, got)
|
||
}
|
||
|
||
func testListMDMCommandsRequiresPerPage(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
_, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, nil)
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "PerPage")
|
||
|
||
_, _, _, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{})
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "PerPage")
|
||
}
|
||
|
||
// testListMDMCommandsPagination exercises the all-hosts per-branch
|
||
// pagination across both Apple and Windows branches. The path inflates the
|
||
// inner LIMIT to per_page*page+per_page so that page N is correct even if all
|
||
// matching rows came from a single branch, and the outer wrap clears
|
||
// After/Page when cursor pagination is used.
|
||
func testListMDMCommandsPagination(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
// Enroll one Windows host and one macOS host.
|
||
winHost := test.NewHost(t, ds, "paginate-win", "1.2.3.4", "paginate-node-win", uuid.NewString(), time.Now(), test.WithPlatform("windows"))
|
||
winDeviceID := windowsEnroll(t, ds, winHost)
|
||
winEnrollment, err := ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, winDeviceID)
|
||
require.NoError(t, err)
|
||
|
||
macHost := test.NewHost(t, ds, "paginate-mac", "1.2.3.5", "paginate-node-mac", uuid.NewString(), time.Now())
|
||
nanoEnroll(t, ds, macHost, false)
|
||
|
||
// Insert 3 Apple commands and 3 Windows commands so both branches contribute.
|
||
commander, _ := createMDMAppleCommanderAndStorage(t, ds)
|
||
const totalApple = 3
|
||
const totalWin = 3
|
||
allUUIDs := make([]string, 0, totalApple+totalWin)
|
||
for range totalApple {
|
||
cmdUUID := uuid.NewString()
|
||
raw := createRawAppleCmd("ProfileList", cmdUUID)
|
||
require.NoError(t, commander.EnqueueCommand(ctx, []string{macHost.UUID}, raw))
|
||
allUUIDs = append(allUUIDs, cmdUUID)
|
||
}
|
||
winUUIDs := make([]string, 0, totalWin)
|
||
for range totalWin {
|
||
cmdUUID := uuid.NewString()
|
||
require.NoError(t, ds.MDMWindowsInsertCommandForHosts(ctx, []string{winDeviceID}, &fleet.MDMWindowsCommand{
|
||
CommandUUID: cmdUUID,
|
||
RawCommand: []byte("<Exec></Exec>"),
|
||
TargetLocURI: "./test/uri",
|
||
}))
|
||
winUUIDs = append(winUUIDs, cmdUUID)
|
||
allUUIDs = append(allUUIDs, cmdUUID)
|
||
}
|
||
|
||
// Mark one Windows command as responded so the results-backed branch of the
|
||
// internal Windows UNION ALL is exercised. The dedupe NOT EXISTS clause
|
||
// must still keep the command from appearing twice across the pagination.
|
||
respondedWinUUID := winUUIDs[0]
|
||
_, err = ds.MDMWindowsSaveResponse(ctx, winEnrollment, fleet.EnrichedSyncML{
|
||
SyncML: &fleet.SyncML{Raw: []byte("<xml></xml>")},
|
||
CmdRefUUIDToStatus: map[string]fleet.SyncMLCmd{
|
||
respondedWinUUID: {Data: ptr.String("200")},
|
||
},
|
||
CmdRefUUIDs: []string{respondedWinUUID},
|
||
}, []string{})
|
||
require.NoError(t, err)
|
||
|
||
sort.Strings(allUUIDs)
|
||
totalCount := len(allUUIDs)
|
||
|
||
t.Run("page-based across branches", func(t *testing.T) {
|
||
// PerPage=2 across 6 commands => 3 pages of 2 each.
|
||
const perPage = 2
|
||
seen := make(map[string]bool)
|
||
for page := uint(0); page*perPage < uint(totalCount); page++ { //nolint:gosec
|
||
cmds, _, meta, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
Page: page,
|
||
PerPage: perPage,
|
||
IncludeMetadata: true,
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.LessOrEqual(t, len(cmds), perPage)
|
||
|
||
start := int(page * perPage)
|
||
end := min(start+perPage, totalCount)
|
||
expected := allUUIDs[start:end]
|
||
got := make([]string, 0, len(cmds))
|
||
for _, c := range cmds {
|
||
require.False(t, seen[c.CommandUUID], "duplicate command UUID across pages: %s", c.CommandUUID)
|
||
seen[c.CommandUUID] = true
|
||
got = append(got, c.CommandUUID)
|
||
}
|
||
require.Equal(t, expected, got, "page %d", page)
|
||
|
||
require.Equal(t, page > 0, meta.HasPreviousResults, "page %d HasPreviousResults", page)
|
||
require.Equal(t, end < totalCount, meta.HasNextResults, "page %d HasNextResults", page)
|
||
}
|
||
require.Len(t, seen, totalCount)
|
||
})
|
||
|
||
t.Run("cursor walks all rows without overlap", func(t *testing.T) {
|
||
const perPage = 2
|
||
seen := make(map[string]bool)
|
||
var after string
|
||
for {
|
||
cmds, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
PerPage: perPage,
|
||
After: after,
|
||
IncludeMetadata: true,
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
if len(cmds) == 0 {
|
||
break
|
||
}
|
||
for _, c := range cmds {
|
||
require.False(t, seen[c.CommandUUID], "duplicate command UUID across cursor pages: %s", c.CommandUUID)
|
||
seen[c.CommandUUID] = true
|
||
}
|
||
after = cmds[len(cmds)-1].CommandUUID
|
||
if len(cmds) < perPage {
|
||
break
|
||
}
|
||
}
|
||
require.Len(t, seen, totalCount)
|
||
})
|
||
|
||
t.Run("outer clears Page when After is set", func(t *testing.T) {
|
||
// Without the outer-wrap clearing of Page, Page=99 with PerPage=2 would
|
||
// add OFFSET 198 on top of the cursor filter and return zero rows even
|
||
// though plenty of rows follow allUUIDs[0]. With the clearing, the call
|
||
// must return the page-sized slice immediately after the cursor.
|
||
cmds, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{
|
||
OrderKey: "command_uuid",
|
||
OrderDirection: fleet.OrderAscending,
|
||
PerPage: 2,
|
||
Page: 99,
|
||
After: allUUIDs[0],
|
||
IncludeMetadata: true,
|
||
},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Equal(t, allUUIDs[1:3], extractCommandUUIDs(cmds))
|
||
})
|
||
}
|
||
|
||
func extractCommandUUIDs(cmds []*fleet.MDMCommand) []string {
|
||
out := make([]string, 0, len(cmds))
|
||
for _, c := range cmds {
|
||
out = append(out, c.CommandUUID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func testListMDMCommandsOrderKeys(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
macH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "ord-host",
|
||
OsqueryHostID: ptr.String("ord-osq"),
|
||
NodeKey: ptr.String("ord-nk"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "ORDABC",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, macH, false)
|
||
|
||
commander, _ := createMDMAppleCommanderAndStorage(t, ds)
|
||
for range 3 {
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, createRawAppleCmd("ProfileList", uuid.NewString()))
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
for _, key := range []string{"command_uuid", "request_type", "status", "updated_at", "hostname", "host_uuid", "name"} {
|
||
t.Run("order_"+key, func(t *testing.T) {
|
||
cmds, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: key, PerPage: 5},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 3)
|
||
})
|
||
}
|
||
|
||
t.Run("rejects_unknown_key", func(t *testing.T) {
|
||
_, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column", PerPage: 5},
|
||
},
|
||
)
|
||
require.Error(t, err)
|
||
var invalidKeyErr common_mysql.InvalidOrderKeyError
|
||
require.ErrorAs(t, err, &invalidKeyErr)
|
||
})
|
||
|
||
// the host-identifier branch uses a separate query; confirm it shares the allowlist
|
||
t.Run("rejects_unknown_key_host_identifier", func(t *testing.T) {
|
||
_, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column", PerPage: 5},
|
||
Filters: fleet.MDMCommandFilters{HostIdentifier: macH.UUID},
|
||
},
|
||
)
|
||
require.Error(t, err)
|
||
var invalidKeyErr common_mysql.InvalidOrderKeyError
|
||
require.ErrorAs(t, err, &invalidKeyErr)
|
||
})
|
||
|
||
t.Run("after_pagination_with_allowed_key", func(t *testing.T) {
|
||
cmds, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1, IncludeMetadata: true},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
afterCursor := cmds[0].CommandUUID
|
||
next, _, _, err := ds.ListMDMCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1, After: afterCursor},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, next, 1)
|
||
require.NotEqual(t, afterCursor, next[0].CommandUUID)
|
||
})
|
||
}
|
||
|
||
func testListMDMAppleCommandsOrderKeys(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
macH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "ord-apple-host",
|
||
OsqueryHostID: ptr.String("ord-apple-osq"),
|
||
NodeKey: ptr.String("ord-apple-nk"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "ORDA1",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, macH, false)
|
||
|
||
commander, _ := createMDMAppleCommanderAndStorage(t, ds)
|
||
for range 2 {
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, createRawAppleCmd("ProfileList", uuid.NewString()))
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
for _, key := range []string{"command_uuid", "request_type", "status", "updated_at", "hostname", "device_id"} {
|
||
t.Run("order_"+key, func(t *testing.T) {
|
||
cmds, err := ds.ListMDMAppleCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: key, PerPage: 5},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 2)
|
||
})
|
||
}
|
||
|
||
t.Run("rejects_unknown_key", func(t *testing.T) {
|
||
_, err := ds.ListMDMAppleCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column"},
|
||
},
|
||
)
|
||
require.Error(t, err)
|
||
})
|
||
|
||
t.Run("after_pagination_with_allowed_key", func(t *testing.T) {
|
||
cmds, err := ds.ListMDMAppleCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
afterCursor := cmds[0].CommandUUID
|
||
next, err := ds.ListMDMAppleCommands(
|
||
ctx,
|
||
fleet.TeamFilter{User: test.UserAdmin},
|
||
&fleet.MDMCommandListOptions{
|
||
ListOptions: fleet.ListOptions{OrderKey: "command_uuid", PerPage: 1, After: afterCursor},
|
||
},
|
||
)
|
||
require.NoError(t, err)
|
||
require.Len(t, next, 1)
|
||
require.NotEqual(t, afterCursor, next[0].CommandUUID)
|
||
})
|
||
}
|
||
|
||
func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
applyAndExpect := func(
|
||
newAppleSet []*fleet.MDMAppleConfigProfile,
|
||
newWindowsSet []*fleet.MDMWindowsConfigProfile,
|
||
newAppleDeclSet []*fleet.MDMAppleDeclaration,
|
||
newAndroidSet []*fleet.MDMAndroidConfigProfile,
|
||
tmID *uint,
|
||
wantApple []*fleet.MDMAppleConfigProfile,
|
||
wantWindows []*fleet.MDMWindowsConfigProfile,
|
||
wantAppleDecl []*fleet.MDMAppleDeclaration,
|
||
wantAndroid []*fleet.MDMAndroidConfigProfile,
|
||
wantUpdates fleet.MDMProfilesUpdates,
|
||
) {
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet, newAndroidSet, nil)
|
||
require.NoError(t, err)
|
||
expectAppleProfiles(t, ds, tmID, wantApple)
|
||
expectWindowsProfiles(t, ds, tmID, wantWindows)
|
||
expectAppleDeclarations(t, ds, tmID, wantAppleDecl)
|
||
expectAndroidProfiles(t, ds, tmID, wantAndroid)
|
||
assert.Equal(t, wantUpdates, updates)
|
||
}
|
||
|
||
withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile {
|
||
p.TeamID = &tmID
|
||
return p
|
||
}
|
||
|
||
withTeamIDDecl := func(d *fleet.MDMAppleDeclaration, tmID uint) *fleet.MDMAppleDeclaration {
|
||
d.TeamID = &tmID
|
||
return d
|
||
}
|
||
|
||
withTeamIDWindows := func(p *fleet.MDMWindowsConfigProfile, tmID uint) *fleet.MDMWindowsConfigProfile {
|
||
p.TeamID = &tmID
|
||
return p
|
||
}
|
||
|
||
withTeamIDAndroid := func(p *fleet.MDMAndroidConfigProfile, tmID uint) *fleet.MDMAndroidConfigProfile {
|
||
p.TeamID = &tmID
|
||
return p
|
||
}
|
||
|
||
// empty set for no team (Apple, Windows and Android)
|
||
applyAndExpect(nil, nil, nil, nil, nil, nil, nil, nil, nil, fleet.MDMProfilesUpdates{})
|
||
|
||
// single Apple, Windows and Android profile set for a specific team
|
||
applyAndExpect(
|
||
[]*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")},
|
||
[]*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")},
|
||
[]*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")},
|
||
[]*fleet.MDMAndroidConfigProfile{androidConfigProfileForTest(t, "A1", nil)},
|
||
ptr.Uint(1),
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
withTeamIDApple(configProfileForTest(t, "N1", "I1", "a"), 1),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{withTeamIDDecl(declForTest("D1", "D1", "foo"), 1)},
|
||
[]*fleet.MDMAndroidConfigProfile{withTeamIDAndroid(androidConfigProfileForTest(t, "A1", nil), 1)},
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
// single Apple, Windows and Android profile set for no team
|
||
applyAndExpect(
|
||
[]*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")},
|
||
[]*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")},
|
||
[]*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")},
|
||
[]*fleet.MDMAndroidConfigProfile{androidConfigProfileForTest(t, "A1", nil)},
|
||
nil,
|
||
[]*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")},
|
||
[]*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")},
|
||
[]*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")},
|
||
[]*fleet.MDMAndroidConfigProfile{androidConfigProfileForTest(t, "A1", nil)},
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
// new Apple, Windows and Android profile sets for a specific team
|
||
applyAndExpect(
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "N1", "I1", "a"), // unchanged
|
||
configProfileForTest(t, "N2", "I2", "b"),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W1", "l1"), // unchanged
|
||
windowsConfigProfileForTest(t, "W2", "l2"),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo"), // unchanged
|
||
declForTest("D2", "D2", "foo"),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "A1", nil), // unchanged
|
||
androidConfigProfileForTest(t, "A2", nil),
|
||
},
|
||
ptr.Uint(1),
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
withTeamIDApple(configProfileForTest(t, "N1", "I1", "a"), 1),
|
||
withTeamIDApple(configProfileForTest(t, "N2", "I2", "b"), 1),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1),
|
||
withTeamIDWindows(windowsConfigProfileForTest(t, "W2", "l2"), 1),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
withTeamIDDecl(declForTest("D1", "D1", "foo"), 1),
|
||
withTeamIDDecl(declForTest("D2", "D2", "foo"), 1),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
withTeamIDAndroid(androidConfigProfileForTest(t, "A1", nil), 1),
|
||
withTeamIDAndroid(androidConfigProfileForTest(t, "A2", nil), 1),
|
||
},
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
// edited profiles, unchanged profiles, and new profiles for a specific team
|
||
applyAndExpect(
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "N1", "I1", "a-updated"), // content updated
|
||
configProfileForTest(t, "N2", "I2", "b"), // unchanged
|
||
configProfileForTest(t, "N3", "I3", "c"), // new
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W1", "l1-updated"), // content updated
|
||
windowsConfigProfileForTest(t, "W2", "l2"), // unchanged
|
||
windowsConfigProfileForTest(t, "W3", "l3"), // new
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo-updated"), // content updated
|
||
declForTest("D2", "D2", "foo"), // unchanged
|
||
declForTest("D3", "D3", "bar"), // new
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "A1", map[string]any{"cameraDisabled": true}), // Content updated
|
||
androidConfigProfileForTest(t, "A2", nil), // unchanged
|
||
androidConfigProfileForTest(t, "A3", nil), // new
|
||
},
|
||
ptr.Uint(1),
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
withTeamIDApple(configProfileForTest(t, "N1", "I1", "a-updated"), 1),
|
||
withTeamIDApple(configProfileForTest(t, "N2", "I2", "b"), 1),
|
||
withTeamIDApple(configProfileForTest(t, "N3", "I3", "c"), 1),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1-updated"), 1),
|
||
withTeamIDWindows(windowsConfigProfileForTest(t, "W2", "l2"), 1),
|
||
withTeamIDWindows(windowsConfigProfileForTest(t, "W3", "l3"), 1),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
withTeamIDDecl(declForTest("D1", "D1", "foo-updated"), 1),
|
||
withTeamIDDecl(declForTest("D2", "D2", "foo"), 1),
|
||
withTeamIDDecl(declForTest("D3", "D3", "bar"), 1),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
withTeamIDAndroid(androidConfigProfileForTest(t, "A1", map[string]any{"cameraDisabled": true}), 1),
|
||
withTeamIDAndroid(androidConfigProfileForTest(t, "A2", nil), 1),
|
||
withTeamIDAndroid(androidConfigProfileForTest(t, "A3", nil), 1),
|
||
},
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
// new Apple, Windows and Android profiles to no team
|
||
applyAndExpect(
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "N4", "I4", "d"),
|
||
configProfileForTest(t, "N5", "I5", "e"),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W4", "l4"),
|
||
windowsConfigProfileForTest(t, "W5", "l5"),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
declForTest("D5", "D4", "foo"),
|
||
declForTest("D4", "D5", "foo"),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "A4", nil),
|
||
androidConfigProfileForTest(t, "A5", nil),
|
||
},
|
||
nil,
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "N4", "I4", "d"),
|
||
configProfileForTest(t, "N5", "I5", "e"),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W4", "l4"),
|
||
windowsConfigProfileForTest(t, "W5", "l5"),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
declForTest("D5", "D4", "foo"),
|
||
declForTest("D4", "D5", "foo"),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "A4", nil),
|
||
androidConfigProfileForTest(t, "A5", nil),
|
||
},
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
// Apply the same profiles again -- no update should be detected
|
||
applyAndExpect(
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "N4", "I4", "d"),
|
||
configProfileForTest(t, "N5", "I5", "e"),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W4", "l4"),
|
||
windowsConfigProfileForTest(t, "W5", "l5"),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
declForTest("D5", "D4", "foo"),
|
||
declForTest("D4", "D5", "foo"),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "A4", nil),
|
||
androidConfigProfileForTest(t, "A5", nil),
|
||
},
|
||
nil,
|
||
[]*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "N4", "I4", "d"),
|
||
configProfileForTest(t, "N5", "I5", "e"),
|
||
},
|
||
[]*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W4", "l4"),
|
||
windowsConfigProfileForTest(t, "W5", "l5"),
|
||
},
|
||
[]*fleet.MDMAppleDeclaration{
|
||
declForTest("D5", "D4", "foo"),
|
||
declForTest("D4", "D5", "foo"),
|
||
},
|
||
[]*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "A4", nil),
|
||
androidConfigProfileForTest(t, "A5", nil),
|
||
},
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: false, AndroidConfigProfile: false},
|
||
)
|
||
|
||
// Test Case 8: Clear profiles for a specific team
|
||
applyAndExpect(nil, nil, nil, nil, ptr.Uint(1), nil, nil, nil, nil,
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
// create some labels to test batch-setting label-scoped declarations
|
||
lblExcl, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-label-1", Query: "select 1"})
|
||
require.NoError(t, err)
|
||
lblExcl2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-label-2", Query: "select 2"})
|
||
require.NoError(t, err)
|
||
lblInclAny, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-label-3", Query: "select 3"})
|
||
require.NoError(t, err)
|
||
lblInclAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-label-4", Query: "select 4"})
|
||
require.NoError(t, err)
|
||
lblInclAll, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclall-label-5", Query: "select 5"})
|
||
require.NoError(t, err)
|
||
lblInclAll2, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclall-label-6", Query: "select 6"})
|
||
require.NoError(t, err)
|
||
|
||
// we only care about declarations here, as batch-setting labels for profiles
|
||
// is tested elsewhere.
|
||
applyAndExpect(nil, nil, []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo", lblExcl, lblExcl2),
|
||
declForTest("D2", "D2", "foo", lblInclAll, lblInclAll2),
|
||
}, nil, nil,
|
||
nil, nil, []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo", lblExcl, lblExcl2),
|
||
declForTest("D2", "D2", "foo", lblInclAll, lblInclAll2),
|
||
}, nil,
|
||
// this removed the apple, windows and android profiles for no team, so updated is true
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true, AndroidConfigProfile: true},
|
||
)
|
||
|
||
applyAndExpect(nil, nil, []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo", lblInclAny, lblInclAny2),
|
||
declForTest("D2", "D2", "foo"),
|
||
}, nil, nil,
|
||
nil, nil, []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo", lblInclAny, lblInclAny2),
|
||
declForTest("D2", "D2", "foo"),
|
||
}, nil,
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: true, AndroidConfigProfile: false},
|
||
)
|
||
applyAndExpect(nil, nil, []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo"),
|
||
}, nil, nil,
|
||
nil, nil, []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "foo"),
|
||
}, nil,
|
||
fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: true, AndroidConfigProfile: false},
|
||
)
|
||
}
|
||
|
||
func testBatchSetMDMProfilesSoftwareUpdateTracking(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
winOSUpdate := func(name string) *fleet.MDMWindowsConfigProfile {
|
||
return &fleet.MDMWindowsConfigProfile{
|
||
Name: name,
|
||
SyncML: fmt.Appendf(nil,
|
||
`<Replace><Item><Target><LocURI>./Device%s/Install</LocURI></Target></Item></Replace>`,
|
||
syncml.FleetOSUpdateTargetLocURI),
|
||
}
|
||
}
|
||
appleOSUpdate := func(name, identifier string) *fleet.MDMAppleDeclaration {
|
||
return &fleet.MDMAppleDeclaration{
|
||
Name: name,
|
||
Identifier: identifier,
|
||
RawJSON: json.RawMessage(fmt.Sprintf(`{
|
||
"Type": %q,
|
||
"Identifier": %q,
|
||
"Payload": {"TargetOSVersion": "14.0", "TargetLocalDateTime": "2025-01-01T12:00:00"}
|
||
}`, apple_mdm.DeclarationTypeSoftwareUpdate, identifier)),
|
||
}
|
||
}
|
||
|
||
// A batch with an OS-update Windows profile and Apple declaration tracks both.
|
||
_, err := ds.BatchSetMDMProfiles(ctx, nil, nil,
|
||
[]*fleet.MDMWindowsConfigProfile{winOSUpdate("win-os")},
|
||
[]*fleet.MDMAppleDeclaration{appleOSUpdate("apple-os", "com.fleet.os")}, nil, nil)
|
||
require.NoError(t, err)
|
||
|
||
winConfigured, err := ds.HasWindowsUpdateConfigProfileConfigured(ctx, 0)
|
||
require.NoError(t, err)
|
||
require.True(t, winConfigured)
|
||
appleConfigured, err := ds.HasAppleUpdateConfigProfileConfigured(ctx, 0)
|
||
require.NoError(t, err)
|
||
require.True(t, appleConfigured)
|
||
|
||
// Re-applying a batch without the OS-update profiles deletes them, and the
|
||
// tracking rows are cleared via ON DELETE CASCADE.
|
||
_, err = ds.BatchSetMDMProfiles(ctx, nil, nil,
|
||
[]*fleet.MDMWindowsConfigProfile{{Name: "other", SyncML: []byte("<Replace></Replace>")}},
|
||
nil, nil, nil)
|
||
require.NoError(t, err)
|
||
|
||
winConfigured, err = ds.HasWindowsUpdateConfigProfileConfigured(ctx, 0)
|
||
require.NoError(t, err)
|
||
require.False(t, winConfigured)
|
||
appleConfigured, err = ds.HasAppleUpdateConfigProfileConfigured(ctx, 0)
|
||
require.NoError(t, err)
|
||
require.False(t, appleConfigured)
|
||
}
|
||
|
||
// simulateBrokenLabel nulls label_id in profile and declaration label tables for the
|
||
// given label name, simulating the broken-label state that previously occurred automatically
|
||
// via ON DELETE SET NULL (now ON DELETE RESTRICT prevents label deletion while referenced).
|
||
func simulateBrokenLabel(t *testing.T, ds *Datastore, ctx context.Context, labelName string) {
|
||
t.Helper()
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
if _, err := tx.ExecContext(ctx, `UPDATE mdm_configuration_profile_labels SET label_id = NULL WHERE label_name = ?`, labelName); err != nil {
|
||
return err
|
||
}
|
||
_, err := tx.ExecContext(ctx, `UPDATE mdm_declaration_labels SET label_id = NULL WHERE label_name = ?`, labelName)
|
||
return err
|
||
})
|
||
}
|
||
|
||
func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
opts := fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}
|
||
winProf := []byte("<Replace></Replace>")
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test team"})
|
||
require.NoError(t, err)
|
||
|
||
// both profile tables are empty
|
||
profs, meta, err := ds.ListMDMConfigProfiles(ctx, nil, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 0)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
// add fleet-managed Apple profiles for the team and globally
|
||
for idf := range mobileconfig.FleetPayloadIdentifiers() {
|
||
_, err = ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("name_"+idf, idf, team.ID), nil)
|
||
require.NoError(t, err)
|
||
_, err = ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("name_"+idf, idf, 0), nil)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// still returns no result
|
||
profs, meta, err = ds.ListMDMConfigProfiles(ctx, nil, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 0)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
profs, meta, err = ds.ListMDMConfigProfiles(ctx, &team.ID, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 0)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
// add fleet-managed Windows profiles for the team and globally
|
||
for name := range mdm_types.FleetReservedProfileNames() {
|
||
_, err = ds.NewMDMWindowsConfigProfile(
|
||
ctx,
|
||
fleet.MDMWindowsConfigProfile{Name: name, TeamID: &team.ID, SyncML: winProf},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
_, err = ds.NewMDMWindowsConfigProfile(
|
||
ctx,
|
||
fleet.MDMWindowsConfigProfile{Name: name, TeamID: nil, SyncML: winProf},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// still returns no result
|
||
profs, meta, err = ds.ListMDMConfigProfiles(ctx, nil, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 0)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
profs, meta, err = ds.ListMDMConfigProfiles(ctx, &team.ID, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 0)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
// create a mac profile for global and a Windows profile for team
|
||
profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("A", "A", 0), nil)
|
||
require.NoError(t, err)
|
||
profB, err := ds.NewMDMWindowsConfigProfile(
|
||
ctx,
|
||
fleet.MDMWindowsConfigProfile{Name: "B", TeamID: &team.ID, SyncML: winProf},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
// get global profiles returns the mac one
|
||
profs, meta, err = ds.ListMDMConfigProfiles(ctx, nil, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 1)
|
||
require.Equal(t, profA.Name, profs[0].Name)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
// get team profiles returns the Windows one
|
||
profs, meta, err = ds.ListMDMConfigProfiles(ctx, &team.ID, opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 1)
|
||
require.Equal(t, profB.Name, profs[0].Name)
|
||
require.Equal(t, *meta, fleet.PaginationMetadata{})
|
||
|
||
// create 12 labels for label-based profiles
|
||
var labels []*fleet.Label
|
||
for i := 0; i < 12; i++ {
|
||
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "l" + strconv.Itoa(i), Query: "select 1"})
|
||
require.NoError(t, err)
|
||
labels = append(labels, lbl)
|
||
}
|
||
|
||
// create more profiles and test the pagination with a table-driven test so that
|
||
// global and team both have 9 profiles (including A and B already created above).
|
||
for i := 0; i < 3; i++ {
|
||
inc := i * 6 // e.g. C, D, E, F, G, H on first loop, I, J, K, L, M, N on second loop, O, P, Q, R, S, T on third
|
||
|
||
// create label-based profiles for i==0, meaning CDEF will be label-based
|
||
acp := *generateAppleCP(string(rune('C'+inc)), string(rune('C'+inc)), 0) // C, I and O
|
||
if i == 0 {
|
||
acp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
|
||
{LabelName: labels[0].Name, LabelID: labels[0].ID},
|
||
{LabelName: labels[1].Name, LabelID: labels[1].ID},
|
||
}
|
||
}
|
||
_, err = ds.NewMDMAppleConfigProfile(ctx, acp, nil)
|
||
require.NoError(t, err)
|
||
|
||
acp = *generateAppleCP(string(rune('C'+inc+1)), string(rune('C'+inc+1)), team.ID) // D, J and P
|
||
if i == 0 {
|
||
acp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
|
||
{LabelName: labels[2].Name, LabelID: labels[2].ID},
|
||
{LabelName: labels[3].Name, LabelID: labels[3].ID},
|
||
}
|
||
}
|
||
_, err = ds.NewMDMAppleConfigProfile(ctx, acp, nil)
|
||
require.NoError(t, err)
|
||
|
||
wcp := fleet.MDMWindowsConfigProfile{ // E K and Q
|
||
Name: string(rune('C' + inc + 2)),
|
||
TeamID: nil,
|
||
SyncML: winProf,
|
||
}
|
||
if i == 0 {
|
||
wcp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
|
||
{LabelName: labels[4].Name, LabelID: labels[4].ID},
|
||
{LabelName: labels[5].Name, LabelID: labels[5].ID},
|
||
}
|
||
}
|
||
_, err = ds.NewMDMWindowsConfigProfile(ctx, wcp, nil)
|
||
require.NoError(t, err)
|
||
|
||
wcp = fleet.MDMWindowsConfigProfile{ // F L and R
|
||
Name: string(rune('C' + inc + 3)),
|
||
TeamID: &team.ID,
|
||
SyncML: winProf,
|
||
}
|
||
if i == 0 {
|
||
wcp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
|
||
{LabelName: labels[6].Name, LabelID: labels[6].ID},
|
||
{LabelName: labels[7].Name, LabelID: labels[7].ID},
|
||
}
|
||
}
|
||
_, err = ds.NewMDMWindowsConfigProfile(ctx, wcp, nil)
|
||
require.NoError(t, err)
|
||
|
||
// gcp = Android("google") config profile
|
||
gcp := fleet.MDMAndroidConfigProfile{ // G M and S
|
||
Name: string(rune('C' + inc + 4)),
|
||
TeamID: nil,
|
||
RawJSON: []byte(`{"hello4": "world4"}`),
|
||
}
|
||
|
||
if i == 0 {
|
||
gcp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
|
||
{LabelName: labels[8].Name, LabelID: labels[8].ID},
|
||
{LabelName: labels[9].Name, LabelID: labels[9].ID},
|
||
}
|
||
}
|
||
_, err = ds.NewMDMAndroidConfigProfile(ctx, gcp, nil)
|
||
require.NoError(t, err)
|
||
|
||
gcp = fleet.MDMAndroidConfigProfile{ // H N and T
|
||
Name: string(rune('C' + inc + 5)),
|
||
TeamID: &team.ID,
|
||
RawJSON: []byte(`{"hello5": "world5"}`),
|
||
}
|
||
if i == 0 {
|
||
gcp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
|
||
{LabelName: labels[10].Name, LabelID: labels[10].ID},
|
||
{LabelName: labels[11].Name, LabelID: labels[11].ID},
|
||
}
|
||
}
|
||
_, err = ds.NewMDMAndroidConfigProfile(ctx, gcp, nil)
|
||
require.NoError(t, err)
|
||
}
|
||
// null label references to simulate profiles D, E and G being broken
|
||
// (FK is now RESTRICT so we can no longer delete a referenced label)
|
||
for _, lbl := range []*fleet.Label{labels[3], labels[4], labels[8]} {
|
||
simulateBrokenLabel(t, ds, ctx, lbl.Name)
|
||
}
|
||
profLabels := map[string][]fleet.ConfigurationProfileLabel{
|
||
"C": {
|
||
{LabelName: labels[0].Name, LabelID: labels[0].ID, RequireAll: true},
|
||
{LabelName: labels[1].Name, LabelID: labels[1].ID, RequireAll: true},
|
||
},
|
||
"D": {
|
||
{LabelName: labels[2].Name, LabelID: labels[2].ID, RequireAll: true},
|
||
{LabelName: labels[3].Name, LabelID: 0, Broken: true, RequireAll: true},
|
||
},
|
||
"E": {
|
||
{LabelName: labels[4].Name, LabelID: 0, Broken: true, RequireAll: true},
|
||
{LabelName: labels[5].Name, LabelID: labels[5].ID, RequireAll: true},
|
||
},
|
||
"F": {
|
||
{LabelName: labels[6].Name, LabelID: labels[6].ID, RequireAll: true},
|
||
{LabelName: labels[7].Name, LabelID: labels[7].ID, RequireAll: true},
|
||
},
|
||
"G": {
|
||
{LabelName: labels[8].Name, Broken: true, RequireAll: true},
|
||
{LabelName: labels[9].Name, LabelID: labels[9].ID, RequireAll: true},
|
||
},
|
||
"H": {
|
||
{LabelName: labels[10].Name, LabelID: labels[10].ID, RequireAll: true},
|
||
{LabelName: labels[11].Name, LabelID: labels[11].ID, RequireAll: true},
|
||
},
|
||
}
|
||
|
||
cases := []struct {
|
||
desc string
|
||
tmID *uint
|
||
opts fleet.ListOptions
|
||
wantNames []string
|
||
wantMeta fleet.PaginationMetadata
|
||
}{
|
||
{
|
||
"all global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true},
|
||
[]string{"A", "C", "E", "G", "I", "K", "M", "O", "Q", "S"},
|
||
fleet.PaginationMetadata{},
|
||
},
|
||
{
|
||
"all team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true},
|
||
[]string{"B", "D", "F", "H", "J", "L", "N", "P", "R", "T"},
|
||
fleet.PaginationMetadata{},
|
||
},
|
||
|
||
{
|
||
"page 0 per page 2, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2},
|
||
[]string{"A", "C"},
|
||
fleet.PaginationMetadata{HasNextResults: true},
|
||
},
|
||
{
|
||
"page 1 per page 2, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 1},
|
||
[]string{"E", "G"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 2 per page 2, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 2},
|
||
[]string{"I", "K"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 3 per page 2, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 3},
|
||
[]string{"M", "O"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 4 per page 2, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 4},
|
||
[]string{"Q", "S"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
{
|
||
"page 5 per page 2, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 5},
|
||
[]string{},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
|
||
{
|
||
"page 0 per page 2, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2},
|
||
[]string{"B", "D"},
|
||
fleet.PaginationMetadata{HasNextResults: true},
|
||
},
|
||
{
|
||
"page 1 per page 2, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 1},
|
||
[]string{"F", "H"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 2 per page 2, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 2},
|
||
[]string{"J", "L"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 3 per page 2, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 3},
|
||
[]string{"N", "P"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 4 per page 2, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 4},
|
||
[]string{"R", "T"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
{
|
||
"page 5 per page 2, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 5},
|
||
[]string{},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
{
|
||
"page 0 per page 3, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3},
|
||
[]string{"A", "C", "E"},
|
||
fleet.PaginationMetadata{HasNextResults: true},
|
||
},
|
||
{
|
||
"page 1 per page 3, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 1},
|
||
[]string{"G", "I", "K"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 2 per page 3, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 2},
|
||
[]string{"M", "O", "Q"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 3 per page 3, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 3},
|
||
[]string{"S"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
{
|
||
"page 4 per page 3, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 4},
|
||
[]string{},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
{
|
||
"page 0 per page 3, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3},
|
||
[]string{"B", "D", "F"},
|
||
fleet.PaginationMetadata{HasNextResults: true},
|
||
},
|
||
{
|
||
"page 1 per page 3, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 1},
|
||
[]string{"H", "J", "L"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 2 per page 3, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 2},
|
||
[]string{"N", "P", "R"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true},
|
||
},
|
||
{
|
||
"page 3 per page 3, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 3},
|
||
[]string{"T"},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
{
|
||
"page 4 per page 3, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 4},
|
||
[]string{},
|
||
fleet.PaginationMetadata{HasPreviousResults: true},
|
||
},
|
||
|
||
{
|
||
"no metadata, global",
|
||
nil,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: false, PerPage: 2, Page: 1},
|
||
[]string{"E", "G"},
|
||
fleet.PaginationMetadata{},
|
||
},
|
||
{
|
||
"no metadata, team",
|
||
&team.ID,
|
||
fleet.ListOptions{OrderKey: "name", IncludeMetadata: false, PerPage: 2, Page: 1},
|
||
[]string{"F", "H"},
|
||
fleet.PaginationMetadata{},
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.desc, func(t *testing.T) {
|
||
profs, meta, err := ds.ListMDMConfigProfiles(ctx, c.tmID, c.opts)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, len(c.wantNames))
|
||
|
||
got := make([]string, len(profs))
|
||
for i, p := range profs {
|
||
got[i] = p.Name
|
||
|
||
wantProfs := profLabels[p.Name]
|
||
require.Equal(t, len(wantProfs), len(p.LabelsIncludeAll), "profile name: %s", p.Name)
|
||
if len(wantProfs) > 0 {
|
||
// clear the profile uuids from the labels list
|
||
for i, l := range p.LabelsIncludeAll {
|
||
l.ProfileUUID = ""
|
||
p.LabelsIncludeAll[i] = l
|
||
}
|
||
require.ElementsMatch(t, wantProfs, p.LabelsIncludeAll, "profile name: %s", p.Name)
|
||
}
|
||
}
|
||
require.Equal(t, c.wantNames, got)
|
||
|
||
var gotMeta fleet.PaginationMetadata
|
||
if meta != nil {
|
||
gotMeta = *meta
|
||
}
|
||
require.Equal(t, c.wantMeta, gotMeta)
|
||
})
|
||
}
|
||
|
||
t.Run("rejects_unknown_order_key", func(t *testing.T) {
|
||
_, _, err := ds.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{OrderKey: "h.node_key"})
|
||
require.Error(t, err)
|
||
})
|
||
}
|
||
|
||
type anyProfile struct {
|
||
ProfileUUID string
|
||
Status *fleet.MDMDeliveryStatus
|
||
OperationType fleet.MDMOperationType
|
||
IdentifierOrName string
|
||
}
|
||
|
||
// cleanupStaleWindowsRemoveRows simulates the full Windows profile removal
|
||
// lifecycle (reconciler sends <Delete> → device confirms → row deleted) for
|
||
// remove rows that are NOT expected in the test assertions. Without this,
|
||
// remove rows from previous test phases accumulate and cause count mismatches.
|
||
//
|
||
// Scoped to only the Windows hosts present in the want map so that rows
|
||
// belonging to hosts not in the current assertion are left untouched. This
|
||
// prevents implicitly hiding issues for hosts the test phase doesn't check.
|
||
func cleanupStaleWindowsRemoveRows(t *testing.T, ds *Datastore, want map[*fleet.Host][]anyProfile) {
|
||
// Collect the set of Windows host UUIDs in the assertion and the
|
||
// (profile_uuid, host_uuid) pairs that are expected as remove rows.
|
||
wantWindowsHostUUIDs := make([]string, 0)
|
||
wantRemoveKeys := make(map[string]bool)
|
||
for h, profs := range want {
|
||
if h.Platform != "windows" {
|
||
continue
|
||
}
|
||
wantWindowsHostUUIDs = append(wantWindowsHostUUIDs, h.UUID)
|
||
for _, p := range profs {
|
||
if p.OperationType == fleet.MDMOperationTypeRemove {
|
||
wantRemoveKeys[p.ProfileUUID+"\n"+h.UUID] = true
|
||
}
|
||
}
|
||
}
|
||
if len(wantWindowsHostUUIDs) == 0 {
|
||
return
|
||
}
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
// Only select remove rows for hosts in the current assertion's want map.
|
||
stmt, args, err := sqlx.In(
|
||
`SELECT profile_uuid, host_uuid FROM host_mdm_windows_profiles WHERE operation_type = 'remove' AND host_uuid IN (?)`,
|
||
wantWindowsHostUUIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var rows []struct {
|
||
ProfileUUID string `db:"profile_uuid"`
|
||
HostUUID string `db:"host_uuid"`
|
||
}
|
||
if err := sqlx.SelectContext(context.Background(), q, &rows, stmt, args...); err != nil {
|
||
return err
|
||
}
|
||
for _, r := range rows {
|
||
key := r.ProfileUUID + "\n" + r.HostUUID
|
||
if !wantRemoveKeys[key] {
|
||
if _, err := q.ExecContext(context.Background(),
|
||
`DELETE FROM host_mdm_windows_profiles WHERE profile_uuid = ? AND host_uuid = ? AND operation_type = 'remove'`,
|
||
r.ProfileUUID, r.HostUUID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// assertHostProfiles only asserts the profile UUID, status, and operation.
|
||
func assertHostProfiles(t *testing.T, ds *Datastore, want map[*fleet.Host][]anyProfile) {
|
||
cleanupStaleWindowsRemoveRows(t, ds, want)
|
||
|
||
ctx := context.Background()
|
||
for h, wantProfs := range want {
|
||
var gotProfs []anyProfile
|
||
|
||
switch h.Platform {
|
||
case "windows":
|
||
profs, err := ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
|
||
require.NoError(t, err)
|
||
require.Equal(t, len(wantProfs), len(profs), "host uuid: %s", h.UUID)
|
||
for _, p := range profs {
|
||
gotProfs = append(gotProfs, anyProfile{
|
||
ProfileUUID: p.ProfileUUID,
|
||
Status: p.Status,
|
||
OperationType: p.OperationType,
|
||
IdentifierOrName: p.Name,
|
||
})
|
||
}
|
||
case "android":
|
||
profs, err := ds.GetHostMDMAndroidProfiles(ctx, h.UUID)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, len(wantProfs), "host uuid: %s", h.UUID)
|
||
for _, p := range profs {
|
||
gotProfs = append(gotProfs, anyProfile{
|
||
ProfileUUID: p.ProfileUUID,
|
||
Status: p.Status,
|
||
OperationType: p.OperationType,
|
||
IdentifierOrName: p.Name,
|
||
})
|
||
}
|
||
default:
|
||
profs, err := ds.GetHostMDMAppleProfiles(ctx, h.UUID)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, len(wantProfs), "host uuid: %s", h.UUID)
|
||
for _, p := range profs {
|
||
gotProfs = append(gotProfs, anyProfile{
|
||
ProfileUUID: p.ProfileUUID,
|
||
Status: p.Status,
|
||
OperationType: p.OperationType,
|
||
IdentifierOrName: p.Identifier,
|
||
})
|
||
}
|
||
}
|
||
|
||
sortProfs := func(profs []anyProfile) []anyProfile {
|
||
sort.Slice(profs, func(i, j int) bool {
|
||
l, r := profs[i], profs[j]
|
||
if l.ProfileUUID == r.ProfileUUID {
|
||
return l.OperationType < r.OperationType
|
||
}
|
||
|
||
// default alphabetical comparison
|
||
return l.IdentifierOrName < r.IdentifierOrName
|
||
})
|
||
return profs
|
||
}
|
||
|
||
gotProfs = sortProfs(gotProfs)
|
||
wantProfs = sortProfs(wantProfs)
|
||
for i, wp := range wantProfs {
|
||
gp := gotProfs[i]
|
||
require.Equal(
|
||
t,
|
||
wp.ProfileUUID,
|
||
gp.ProfileUUID,
|
||
"host uuid: %s, prof id or name: %s",
|
||
h.UUID,
|
||
gp.IdentifierOrName,
|
||
)
|
||
require.Equal(
|
||
t,
|
||
wp.Status,
|
||
gp.Status,
|
||
"host uuid: %s, prof id or name: %s",
|
||
h.UUID,
|
||
gp.IdentifierOrName,
|
||
)
|
||
require.Equal(
|
||
t,
|
||
wp.OperationType,
|
||
gp.OperationType,
|
||
"host uuid: %s, prof id or name: %s",
|
||
h.UUID,
|
||
gp.IdentifierOrName,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
// Setup funcs
|
||
|
||
// ===================================================
|
||
// MacOS base tests
|
||
// ===================================================
|
||
baseEarliestInstallDate := time.Now().UTC().Add(-24 * time.Hour).Truncate(time.Second)
|
||
overrideEarliestInstallDate := time.Now().UTC().Add(-6 * time.Hour).Truncate(time.Second)
|
||
|
||
macosBasicTeamProfNoLabelsSetup := func() (uint, *fleet.Host) {
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-1",
|
||
OsqueryHostID: ptr.String("osquery-macos-1"),
|
||
NodeKey: ptr.String("node-key-macos-1"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, host, false)
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "macos team 1"})
|
||
require.NoError(t, err)
|
||
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// create profiles for team 1
|
||
profiles := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "T1.1", "T1.1", "d"),
|
||
configProfileForTest(t, "T1.2", "T1.2", "e"),
|
||
}
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 2)
|
||
|
||
return team.ID, host
|
||
}
|
||
|
||
macosLabeledTeamProfSetup := func() (uint, *fleet.Host) {
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-2",
|
||
OsqueryHostID: ptr.String("osquery-macos-2"),
|
||
NodeKey: ptr.String("node-key-macos-2"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, host, false)
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "macos team 2"})
|
||
require.NoError(t, err)
|
||
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// create profiles for team 1
|
||
profiles := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "T2.1", "T2.1", "d"),
|
||
configProfileForTest(t, "T2.2", "T2.2", "e"),
|
||
configProfileForTest(t, "labeled_prof", "labeled_prof", "labeled_prof"),
|
||
}
|
||
|
||
label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_1"})
|
||
require.NoError(t, err)
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
|
||
var uid string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(
|
||
ctx,
|
||
q,
|
||
&uid,
|
||
`SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`,
|
||
"labeled_prof",
|
||
)
|
||
})
|
||
|
||
// Update label with host membership
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
|
||
host.ID,
|
||
label.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
// Update profile <-> label mapping
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
|
||
uid,
|
||
label.Name,
|
||
label.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 3)
|
||
|
||
return team.ID, host
|
||
}
|
||
|
||
macosLabeledTeamProfWithAdditionalLabeledProfSetup := func() (uint, *fleet.Host) {
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-3",
|
||
OsqueryHostID: ptr.String("osquery-macos-3"),
|
||
NodeKey: ptr.String("node-key-macos-3"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, host, false)
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "macos team 3"})
|
||
require.NoError(t, err)
|
||
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// create profiles for team 1
|
||
profiles := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "T3.1", "T3.1", "d"),
|
||
configProfileForTest(t, "T3.2", "T3.2", "e"),
|
||
configProfileForTest(t, "labeled_prof_2", "labeled_prof_2", "labeled_prof_2"),
|
||
}
|
||
|
||
testLabel2, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_2"})
|
||
require.NoError(t, err)
|
||
|
||
testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_3"})
|
||
require.NoError(t, err)
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
|
||
var uid string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(
|
||
ctx,
|
||
q,
|
||
&uid,
|
||
`SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`,
|
||
"labeled_prof_2",
|
||
)
|
||
})
|
||
|
||
// Update label with host membership
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
|
||
host.ID,
|
||
testLabel2.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
// Update profile <-> label mapping
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
|
||
uid,
|
||
testLabel2.Name,
|
||
testLabel2.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
// Also add mapping to test label 3
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
|
||
uid,
|
||
testLabel3.Name,
|
||
testLabel3.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 3)
|
||
|
||
return team.ID, host
|
||
}
|
||
|
||
macosProfWithBrokenLabelSetup := func() (uint, *fleet.Host) {
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-4",
|
||
OsqueryHostID: ptr.String("osquery-macos-4"),
|
||
NodeKey: ptr.String("node-key-macos-4"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, host, false)
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "macos team 4"})
|
||
require.NoError(t, err)
|
||
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// create profiles for team
|
||
profiles := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "T4.1", "T4.1", "d"),
|
||
configProfileForTest(t, "T4.2", "T4.2", "e"),
|
||
configProfileForTest(t, "broken_label_prof", "broken_label_prof", "broken_label_prof"),
|
||
}
|
||
|
||
testLabel4, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_4"})
|
||
require.NoError(t, err)
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
|
||
var uid string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(
|
||
ctx,
|
||
q,
|
||
&uid,
|
||
`SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`,
|
||
"broken_label_prof",
|
||
)
|
||
})
|
||
|
||
// Update label with host membership
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
|
||
host.ID,
|
||
testLabel4.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
// Update profile <-> label mapping
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
|
||
uid,
|
||
testLabel4.Name,
|
||
testLabel4.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 3)
|
||
|
||
// Simulate the label being broken — direct DeleteLabel is now blocked when
|
||
// referenced by a profile, so we nullify label_id in the join tables instead.
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
if _, err := q.ExecContext(ctx, `UPDATE mdm_configuration_profile_labels SET label_id = NULL WHERE label_id = ?`, testLabel4.ID); err != nil {
|
||
return err
|
||
}
|
||
_, err := q.ExecContext(ctx, `UPDATE mdm_declaration_labels SET label_id = NULL WHERE label_id = ?`, testLabel4.ID)
|
||
return err
|
||
})
|
||
|
||
return team.ID, host
|
||
}
|
||
|
||
macosInstallDateOverrideSetup := func() (uint, *fleet.Host) {
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-5",
|
||
OsqueryHostID: ptr.String("osquery-macos-5"),
|
||
NodeKey: ptr.String("node-key-macos-5"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, host, false)
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "macos team 5"})
|
||
require.NoError(t, err)
|
||
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// create profiles for team 1
|
||
profiles := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "T5.1", "T5.1", "d"),
|
||
configProfileForTest(t, "T5.2", "T5.2", "e"),
|
||
configProfileForTest(t, "T5.3", "T5.3", "f"),
|
||
}
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 3)
|
||
|
||
// We cannot control the generated profile UUIDs here so we need to map them back to the
|
||
// created profiles for test correctness
|
||
for i := 0; i < len(profs); i++ {
|
||
for j := 0; j < len(profiles); j++ {
|
||
if profs[i].Identifier == profiles[j].Identifier {
|
||
profiles[j].ProfileUUID = profs[i].ProfileUUID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"UPDATE mdm_apple_configuration_profiles SET uploaded_at = ? WHERE profile_uuid IN (?, ?, ?)",
|
||
baseEarliestInstallDate,
|
||
profiles[0].ProfileUUID,
|
||
profiles[1].ProfileUUID,
|
||
profiles[2].ProfileUUID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
// Note what we're doing here is overriding install date for the first profile, creating an
|
||
// HMAP entry that doesn't override it for the second, then the third has no corresponding
|
||
// HMAP entry at all(which also means no override).
|
||
err = ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{
|
||
{
|
||
ProfileUUID: profiles[0].ProfileUUID,
|
||
ProfileIdentifier: profiles[0].Identifier,
|
||
ProfileName: profiles[0].Name,
|
||
HostUUID: host.UUID,
|
||
CommandUUID: uuid.NewString(),
|
||
Checksum: profiles[0].Checksum,
|
||
VariablesUpdatedAt: &overrideEarliestInstallDate,
|
||
Status: &fleet.MDMDeliveryVerified,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
Scope: fleet.PayloadScopeSystem,
|
||
},
|
||
{
|
||
ProfileUUID: profiles[1].ProfileUUID,
|
||
ProfileIdentifier: profiles[1].Identifier,
|
||
ProfileName: profiles[1].Name,
|
||
HostUUID: host.UUID,
|
||
CommandUUID: uuid.NewString(),
|
||
Checksum: profiles[1].Checksum,
|
||
Status: &fleet.MDMDeliveryVerified,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
Scope: fleet.PayloadScopeSystem,
|
||
},
|
||
})
|
||
require.NoError(t, err)
|
||
return team.ID, host
|
||
}
|
||
|
||
macosLabeledProfileRulesSetup := func() (uint, *fleet.Host) {
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-6",
|
||
OsqueryHostID: ptr.String("osquery-macos-6"),
|
||
NodeKey: ptr.String("node-key-macos-6"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, host, false)
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "macos team 6"})
|
||
require.NoError(t, err)
|
||
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// Include any labels
|
||
includeAnyMatchedLabel1, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-any-macos-matched-label-1"})
|
||
require.NoError(t, err)
|
||
includeAnyMatchedLabel2, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-any-macos-matched-label-2"})
|
||
require.NoError(t, err)
|
||
includeAnyUnmatchedLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-any-macos-unmatched-label"})
|
||
require.NoError(t, err)
|
||
|
||
// Include all labels
|
||
includeAllMatchedLabel1, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-all-macos-matched-label-1"})
|
||
require.NoError(t, err)
|
||
includeAllMatchedLabel2, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-all-macos-matched-label-2"})
|
||
require.NoError(t, err)
|
||
includeAllUnmatchedLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-all-macos-unmatched-label"})
|
||
require.NoError(t, err)
|
||
|
||
// Exclude labels
|
||
excludeMatchedLabel1, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-macos-matched-label-1"})
|
||
require.NoError(t, err)
|
||
excludeMatchedLabel2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-macos-matched-label-2"})
|
||
require.NoError(t, err)
|
||
excludeUnmatchedLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-macos-unmatched-label"})
|
||
require.NoError(t, err)
|
||
|
||
// create profiles for team
|
||
// include_any_all_match_prof
|
||
// include_any_one_matches_prof
|
||
// include_all_all_match_prof
|
||
// exclude_none_match_prof
|
||
profiles := []*fleet.MDMAppleConfigProfile{
|
||
// Two profiles with no label rules, thus should always be included
|
||
configProfileForTest(t, "T6.1", "T6.1", "g"),
|
||
configProfileForTest(t, "T6.2", "T6.2", "h"),
|
||
|
||
// This profile will use an "include any" rule where the host has both of the labels, thus should be included
|
||
configProfileForTest(t, "include_any_all_match_prof", "include_any_all_match_prof", "i", includeAnyMatchedLabel1, includeAnyMatchedLabel2),
|
||
// This profile will use an "include any" rule where the host has one of the labels, thus should be included
|
||
configProfileForTest(t, "include_any_one_matches_prof", "include_any_one_matches_prof", "j", includeAnyMatchedLabel1, includeAnyUnmatchedLabel),
|
||
// This profile will use an "include any" rule where the host has none of the labels, thus should be excluded
|
||
configProfileForTest(t, "include_any_none_match_prof", "include_any_none_match_prof", "k", includeAnyUnmatchedLabel),
|
||
|
||
// This profile will use an "include all" rule where the host has all of the labels and thus should be included
|
||
configProfileForTest(t, "include_all_all_match_prof", "include_all_all_match_prof", "l", includeAllMatchedLabel1, includeAllMatchedLabel2),
|
||
// This profile will use an "include all" rule where the host has one of the labels and thus should be excluded
|
||
configProfileForTest(t, "include_all_one_matches_prof", "include_all_one_matches_prof", "m", includeAllMatchedLabel1, includeAllUnmatchedLabel),
|
||
// This profile will use an "include any" rule where the host has none of the labels and thus should be excluded
|
||
configProfileForTest(t, "include_all_none_match_prof", "include_all_none_match_prof", "n", includeAllUnmatchedLabel),
|
||
|
||
// This profile will use an "exclude" rule where the host has both of the labels, thus should be excluded
|
||
configProfileForTest(t, "exclude_all_match_prof", "exclude_all_match_prof", "o", excludeMatchedLabel1, excludeMatchedLabel2),
|
||
// This profile will use an "exclude" rule where the host has one of the labels, thus should be excluded
|
||
configProfileForTest(t, "exclude_one_matches_prof", "exclude_one_matches_prof", "p", excludeMatchedLabel1, excludeUnmatchedLabel),
|
||
// This profile will use an "exclude" rule where the host has none of the labels, thus should be included
|
||
configProfileForTest(t, "exclude_none_match_prof", "exclude_none_match_prof", "q", excludeUnmatchedLabel),
|
||
|
||
// This profile will use both an "include all" and "exclude" rule where the host matches the include all rule but also matches the exclude rule, thus should be excluded
|
||
configProfileForTest(t, "include_all_and_exclude_match_prof", "include_all_and_exclude_match_prof", "r", includeAllMatchedLabel1, includeAllMatchedLabel2, excludeMatchedLabel1),
|
||
// This profile will use both an "include all" and "exclude" rule where the host matches the include all rule but does not match the exclude rule, thus should be included
|
||
configProfileForTest(t, "include_all_and_exclude_none_match_prof", "include_all_and_exclude_none_match_prof", "s", includeAllMatchedLabel1, includeAllMatchedLabel2, excludeUnmatchedLabel),
|
||
|
||
// This profile will use both an "include any" and "exclude" rule where the host matches the include any rule but also matches the exclude rule, thus should be excluded
|
||
configProfileForTest(t, "include_any_and_exclude_match_prof", "include_any_and_exclude_match_prof", "t", includeAnyMatchedLabel1, includeAnyUnmatchedLabel, excludeMatchedLabel1),
|
||
// This profile will use both an "include any" and "exclude" rule where the host matches the include any rule but does not match the exclude rule, thus should be included
|
||
configProfileForTest(t, "include_any_and_exclude_none_match_prof", "include_any_and_exclude_none_match_prof", "u", includeAnyMatchedLabel1, includeAnyUnmatchedLabel, excludeUnmatchedLabel),
|
||
}
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
|
||
// Update labels with host membership
|
||
ExecAdhocSQL(
|
||
t, ds, func(db sqlx.ExtContext) error {
|
||
_, err := db.ExecContext(
|
||
context.Background(),
|
||
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)",
|
||
host.ID,
|
||
includeAnyMatchedLabel1.ID,
|
||
host.ID,
|
||
includeAnyMatchedLabel2.ID,
|
||
host.ID,
|
||
includeAllMatchedLabel1.ID,
|
||
host.ID,
|
||
includeAllMatchedLabel2.ID,
|
||
host.ID,
|
||
excludeMatchedLabel1.ID,
|
||
host.ID,
|
||
excludeMatchedLabel2.ID,
|
||
)
|
||
return err
|
||
},
|
||
)
|
||
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 15)
|
||
|
||
return team.ID, host
|
||
}
|
||
|
||
tests := []struct {
|
||
name string
|
||
setupFunc func() (uint, *fleet.Host)
|
||
wantMac map[string]*fleet.ExpectedMDMProfile
|
||
os string
|
||
}{
|
||
{
|
||
name: "macos basic team profiles no labels",
|
||
setupFunc: macosBasicTeamProfNoLabelsSetup,
|
||
wantMac: map[string]*fleet.ExpectedMDMProfile{
|
||
"T1.1": {Identifier: "T1.1"},
|
||
"T1.2": {Identifier: "T1.2"},
|
||
},
|
||
},
|
||
{
|
||
name: "macos labeled team profile",
|
||
setupFunc: macosLabeledTeamProfSetup,
|
||
wantMac: map[string]*fleet.ExpectedMDMProfile{
|
||
"T2.1": {Identifier: "T2.1"},
|
||
"T2.2": {Identifier: "T2.2"},
|
||
"labeled_prof": {Identifier: "labeled_prof"},
|
||
},
|
||
},
|
||
{
|
||
name: "macos labeled team profile with additional labeled profile",
|
||
setupFunc: macosLabeledTeamProfWithAdditionalLabeledProfSetup,
|
||
// Our expected profiles should not include the labeled profile, because it
|
||
// maps to a label that is not applied to the host.
|
||
wantMac: map[string]*fleet.ExpectedMDMProfile{
|
||
"T3.1": {Identifier: "T3.1"},
|
||
"T3.2": {Identifier: "T3.2"},
|
||
},
|
||
},
|
||
{
|
||
name: "macos profile with broken label",
|
||
setupFunc: macosProfWithBrokenLabelSetup,
|
||
// Our expected profiles should not include the labeled profile, because it is broken
|
||
// (the label was deleted)
|
||
wantMac: map[string]*fleet.ExpectedMDMProfile{
|
||
"T4.1": {Identifier: "T4.1"},
|
||
"T4.2": {Identifier: "T4.2"},
|
||
},
|
||
},
|
||
{
|
||
name: "macos basic team profiles no labels, install date overridden on first",
|
||
setupFunc: macosInstallDateOverrideSetup,
|
||
wantMac: map[string]*fleet.ExpectedMDMProfile{
|
||
"T5.1": {Identifier: "T5.1", EarliestInstallDate: overrideEarliestInstallDate},
|
||
"T5.2": {Identifier: "T5.2", EarliestInstallDate: baseEarliestInstallDate},
|
||
"T5.3": {Identifier: "T5.3", EarliestInstallDate: baseEarliestInstallDate},
|
||
},
|
||
},
|
||
{
|
||
name: "macos labels include any/all and exclude rules",
|
||
setupFunc: macosLabeledProfileRulesSetup,
|
||
wantMac: map[string]*fleet.ExpectedMDMProfile{
|
||
"T6.1": {Identifier: "T6.1"},
|
||
"T6.2": {Identifier: "T6.2"},
|
||
"include_any_all_match_prof": {Identifier: "include_any_all_match_prof"},
|
||
"include_any_one_matches_prof": {Identifier: "include_any_one_matches_prof"},
|
||
"include_all_all_match_prof": {Identifier: "include_all_all_match_prof"},
|
||
"exclude_none_match_prof": {Identifier: "exclude_none_match_prof"},
|
||
"include_all_and_exclude_none_match_prof": {Identifier: "include_all_and_exclude_none_match_prof"},
|
||
"include_any_and_exclude_none_match_prof": {Identifier: "include_any_and_exclude_none_match_prof"},
|
||
},
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
teamID, host := tt.setupFunc()
|
||
timeZero := time.Time{}
|
||
if len(tt.wantMac) > 0 {
|
||
got, err := ds.getHostMDMAppleProfilesExpectedForVerification(ctx, teamID, host)
|
||
require.NoError(t, err)
|
||
for k, v := range tt.wantMac {
|
||
require.Contains(t, got, k)
|
||
require.Equal(t, v.Identifier, got[k].Identifier)
|
||
// Only check earliest install date if we are overriding it in the test setup
|
||
if v.EarliestInstallDate != timeZero {
|
||
require.Equal(t, v.EarliestInstallDate, got[k].EarliestInstallDate)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
// create a label
|
||
label := &fleet.Label{
|
||
Name: "my label",
|
||
Description: "a label",
|
||
Query: "select 1 from processes;",
|
||
}
|
||
label, err := ds.NewLabel(ctx, label)
|
||
require.NoError(t, err)
|
||
|
||
// create a macOS config profile
|
||
macOSProfile, err := ds.NewMDMAppleConfigProfile(
|
||
ctx,
|
||
fleet.MDMAppleConfigProfile{
|
||
Name: "DummyTestName",
|
||
Identifier: "DummyTestIdentifier",
|
||
Mobileconfig: mobileconfig.Mobileconfig([]byte("DummyTestMobileconfigBytes")),
|
||
TeamID: nil,
|
||
},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
otherMacProfile, err := ds.NewMDMAppleConfigProfile(
|
||
ctx,
|
||
fleet.MDMAppleConfigProfile{
|
||
Name: "OtherDummyTestName",
|
||
Identifier: "OtherDummyTestIdentifier",
|
||
Mobileconfig: mobileconfig.Mobileconfig([]byte("OtherDummyTestMobileconfigBytes")),
|
||
TeamID: nil,
|
||
},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
// create a Windows config profile
|
||
windowsProfile, err := ds.NewMDMWindowsConfigProfile(
|
||
ctx,
|
||
fleet.MDMWindowsConfigProfile{
|
||
Name: "with-labels",
|
||
TeamID: nil,
|
||
SyncML: []byte("<Replace></Replace>"),
|
||
},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
otherWinProfile, err := ds.NewMDMWindowsConfigProfile(
|
||
ctx,
|
||
fleet.MDMWindowsConfigProfile{
|
||
Name: "other-with-labels",
|
||
TeamID: nil,
|
||
SyncML: []byte("<Replace></Replace>"),
|
||
},
|
||
nil,
|
||
)
|
||
require.NoError(t, err)
|
||
|
||
// assign the label to the "other" profiles, should not change throughout the test
|
||
wantOtherWin := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: otherWinProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID},
|
||
}
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, []string{windowsProfile.ProfileUUID}, "windows")
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
// make it an "exclude" label on the other macos profile
|
||
wantOtherMac := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID, Exclude: true},
|
||
}
|
||
updatedDB, err = batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, []string{macOSProfile.ProfileUUID}, "darwin")
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
|
||
platforms := map[string]string{
|
||
"darwin": macOSProfile.ProfileUUID,
|
||
"windows": windowsProfile.ProfileUUID,
|
||
}
|
||
|
||
for platform, uuid := range platforms {
|
||
expectLabels := func(t *testing.T, profUUID, platform string, want []fleet.ConfigurationProfileLabel) {
|
||
p := platform
|
||
if p == "darwin" {
|
||
p = "apple"
|
||
}
|
||
|
||
query := fmt.Sprintf(
|
||
"SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ?",
|
||
p,
|
||
p,
|
||
)
|
||
|
||
var got []fleet.ConfigurationProfileLabel
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
err := sqlx.SelectContext(ctx, tx, &got, query, profUUID)
|
||
require.NoError(t, err)
|
||
require.Len(t, got, len(want))
|
||
return nil
|
||
})
|
||
require.ElementsMatch(t, want, got)
|
||
}
|
||
|
||
t.Run("empty input "+platform, func(t *testing.T) {
|
||
want := []fleet.ConfigurationProfileLabel{}
|
||
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, want, nil, platform)
|
||
require.NoError(t, err)
|
||
assert.False(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, want)
|
||
// does not change other profiles
|
||
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
|
||
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
|
||
})
|
||
|
||
t.Run("valid input "+platform, func(t *testing.T) {
|
||
profileLabels := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID},
|
||
}
|
||
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
// does not change other profiles
|
||
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
|
||
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
|
||
|
||
// now set it with Exclude mode
|
||
profileLabels = []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
// does not change other profiles
|
||
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
|
||
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
|
||
})
|
||
|
||
t.Run("invalid profile UUID "+platform, func(t *testing.T) {
|
||
invalidProfileLabels := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: "invalid-uuid", LabelName: label.Name, LabelID: label.ID},
|
||
}
|
||
|
||
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.Error(t, err)
|
||
})
|
||
|
||
t.Run("invalid label data "+platform, func(t *testing.T) {
|
||
// invalid id
|
||
invalidProfileLabels := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: label.Name, LabelID: 12345},
|
||
}
|
||
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.Error(t, err)
|
||
|
||
// both invalid
|
||
invalidProfileLabels = []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: "xyz", LabelID: 1235},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.Error(t, err)
|
||
})
|
||
|
||
t.Run("labels are removed "+platform, func(t *testing.T) {
|
||
// create a new label
|
||
newLabel := &fleet.Label{
|
||
Name: "new label" + platform,
|
||
Description: "a label",
|
||
Query: "select 1 from orbit_info;",
|
||
}
|
||
newLabel, err := ds.NewLabel(ctx, newLabel)
|
||
require.NoError(t, err)
|
||
|
||
// apply a batch set with the new label
|
||
profileLabels := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true},
|
||
{ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID, Exclude: true},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
// both are stored in the DB
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
|
||
// batch apply again without the newLabel, and without Exclude flag
|
||
profileLabels = []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
|
||
// batch apply again this time without any label
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, nil, []string{uuid}, platform)
|
||
require.NoError(t, err)
|
||
assert.True(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, nil)
|
||
|
||
// does not change other profiles
|
||
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
|
||
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
|
||
|
||
// batch apply again with no change returns false
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, nil, []string{uuid}, platform)
|
||
require.NoError(t, err)
|
||
assert.False(t, updatedDB)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, nil)
|
||
})
|
||
|
||
t.Run("broken label association is cleared on upsert "+platform, func(t *testing.T) {
|
||
// Regression test for https://github.com/fleetdm/fleet/issues/42637.
|
||
startingLabel := &fleet.Label{
|
||
Name: "broken-label-" + platform,
|
||
Query: "select 1 from osquery_info;",
|
||
}
|
||
startingLabel, err := ds.NewLabel(ctx, startingLabel)
|
||
require.NoError(t, err)
|
||
|
||
profileLabels := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: startingLabel.Name, LabelID: startingLabel.ID, Exclude: true},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
|
||
// Simulate the label being deleted: NULL the label_id directly. This is
|
||
// what FK ON DELETE SET NULL does when the underlying label row is gone.
|
||
p := platform
|
||
if p == "darwin" {
|
||
p = "apple"
|
||
}
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
_, err := tx.ExecContext(ctx,
|
||
fmt.Sprintf(`UPDATE mdm_configuration_profile_labels SET label_id = NULL WHERE %s_profile_uuid = ? AND label_name = ?`, p),
|
||
uuid, startingLabel.Name)
|
||
return err
|
||
})
|
||
|
||
// Sanity: broken row exists.
|
||
var brokenCount int
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, tx, &brokenCount,
|
||
fmt.Sprintf(`SELECT COUNT(*) FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ? AND label_id IS NULL`, p),
|
||
uuid)
|
||
})
|
||
require.Equal(t, 1, brokenCount, "expected broken row to exist before re-upsert")
|
||
|
||
// Re-apply with a different label, mimicking gitops switching from
|
||
// labels_exclude_any: [startingLabel] to labels_include_any: [switchTarget].
|
||
switchTarget := &fleet.Label{
|
||
Name: "switch-target-" + platform,
|
||
Query: "select 1 from osquery_info;",
|
||
}
|
||
switchTarget, err = ds.NewLabel(ctx, switchTarget)
|
||
require.NoError(t, err)
|
||
|
||
profileLabels = []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: switchTarget.Name, LabelID: switchTarget.ID, Exclude: false},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// The broken row must be gone
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, tx, &brokenCount,
|
||
fmt.Sprintf(`SELECT COUNT(*) FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ? AND label_id IS NULL`, p),
|
||
uuid)
|
||
})
|
||
require.Equal(t, 0, brokenCount, "broken (NULL label_id) row should have been cleared")
|
||
|
||
// Only switchTarget should remain.
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
|
||
// Other profiles must remain untouched.
|
||
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
|
||
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
|
||
})
|
||
|
||
t.Run("same label name recreated after deletion "+platform, func(t *testing.T) {
|
||
// Regression test for https://github.com/fleetdm/fleet/issues/44950.
|
||
// Reproduces: global label referenced by a profile is deleted (label_id
|
||
// becomes NULL via ON DELETE SET NULL), then a fleet-level label with
|
||
// the SAME name is created and re-applied. The SELECT in
|
||
// batchSetProfileLabelAssociationsDB used to fail with
|
||
// "converting NULL to uint is unsupported" because it selected the
|
||
// broken row's NULL label_id into a uint field.
|
||
origLabel := &fleet.Label{
|
||
Name: "gitops-label-" + platform,
|
||
Query: "select 1 from osquery_info;",
|
||
}
|
||
origLabel, err := ds.NewLabel(ctx, origLabel)
|
||
require.NoError(t, err)
|
||
|
||
// Associate profile with original label
|
||
profileLabels := []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: origLabel.Name, LabelID: origLabel.ID},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// Simulate the label being deleted: nullify label_id in the join table
|
||
// first (RESTRICT FK now blocks direct delete), then remove the label row.
|
||
// The test then re-creates a label with the same name to verify the
|
||
// broken-link upsert path.
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
if _, err := tx.ExecContext(ctx, `UPDATE mdm_configuration_profile_labels SET label_id = NULL WHERE label_id = ?`, origLabel.ID); err != nil {
|
||
return err
|
||
}
|
||
_, err := tx.ExecContext(ctx, `DELETE FROM labels WHERE id = ?`, origLabel.ID)
|
||
return err
|
||
})
|
||
|
||
p := platform
|
||
if p == "darwin" {
|
||
p = "apple"
|
||
}
|
||
|
||
// Sanity check: the broken (NULL) row exists.
|
||
var brokenBefore int
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, tx, &brokenBefore,
|
||
fmt.Sprintf(`SELECT COUNT(*) FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ? AND label_id IS NULL`, p),
|
||
uuid)
|
||
})
|
||
require.Equal(t, 1, brokenBefore, "expected broken row after label deletion")
|
||
|
||
// Create a new label with the SAME name (simulates moving from global to fleet scope)
|
||
newLabel := &fleet.Label{
|
||
Name: origLabel.Name,
|
||
Query: "select 1 from osquery_info;",
|
||
}
|
||
newLabel, err = ds.NewLabel(ctx, newLabel)
|
||
require.NoError(t, err)
|
||
|
||
// Re-apply with the new label ID but same name — this is the
|
||
// call that used to fail with "converting NULL to uint".
|
||
profileLabels = []fleet.ConfigurationProfileLabel{
|
||
{ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID},
|
||
}
|
||
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, nil, platform)
|
||
return err
|
||
})
|
||
require.NoError(t, err, "batchSetProfileLabelAssociationsDB should not fail when a broken row exists with the same label name")
|
||
|
||
// Verify only the new label association exists (no broken row)
|
||
var brokenAfter int
|
||
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, tx, &brokenAfter,
|
||
fmt.Sprintf(`SELECT COUNT(*) FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ? AND label_id IS NULL`, p),
|
||
uuid)
|
||
})
|
||
require.Equal(t, 0, brokenAfter, "broken (NULL label_id) row should have been cleaned up")
|
||
|
||
// Verify the correct label association exists with the new label ID.
|
||
expectLabels(t, uuid, platform, profileLabels)
|
||
|
||
// Other profiles must remain untouched.
|
||
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
|
||
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
|
||
})
|
||
}
|
||
|
||
t.Run("unsupported platform", func(t *testing.T) {
|
||
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
_, err := batchSetProfileLabelAssociationsDB(
|
||
ctx,
|
||
tx,
|
||
[]fleet.ConfigurationProfileLabel{{}},
|
||
nil,
|
||
"unsupported",
|
||
)
|
||
return err
|
||
})
|
||
require.Error(t, err)
|
||
})
|
||
}
|
||
|
||
func testMDMEULA(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
eula := &fleet.MDMEULA{
|
||
Token: uuid.New().String(),
|
||
Name: "eula.pdf",
|
||
Bytes: []byte("contents"),
|
||
Sha256: []byte("test-sha256"),
|
||
}
|
||
|
||
err := ds.MDMInsertEULA(ctx, eula)
|
||
require.NoError(t, err)
|
||
|
||
var ae fleet.AlreadyExistsError
|
||
err = ds.MDMInsertEULA(ctx, eula)
|
||
require.ErrorAs(t, err, &ae)
|
||
|
||
gotEULA, err := ds.MDMGetEULAMetadata(ctx)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, gotEULA.CreatedAt)
|
||
require.Equal(t, eula.Token, gotEULA.Token)
|
||
require.Equal(t, eula.Name, gotEULA.Name)
|
||
|
||
gotEULABytes, err := ds.MDMGetEULABytes(ctx, eula.Token)
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, eula.Bytes, gotEULABytes.Bytes)
|
||
require.Equal(t, eula.Name, gotEULABytes.Name)
|
||
|
||
err = ds.MDMDeleteEULA(ctx, eula.Token)
|
||
require.NoError(t, err)
|
||
|
||
var nfe fleet.NotFoundError
|
||
_, err = ds.MDMGetEULAMetadata(ctx)
|
||
require.ErrorAs(t, err, &nfe)
|
||
_, err = ds.MDMGetEULABytes(ctx, eula.Token)
|
||
require.ErrorAs(t, err, &nfe)
|
||
err = ds.MDMDeleteEULA(ctx, eula.Token)
|
||
require.ErrorAs(t, err, &nfe)
|
||
|
||
err = ds.MDMInsertEULA(ctx, eula)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
scepDepot, err := ds.NewSCEPDepot()
|
||
require.NoError(t, err)
|
||
|
||
nanoStorage, err := ds.NewMDMAppleMDMStorage()
|
||
require.NoError(t, err)
|
||
|
||
addCert := func(notAfter time.Time, h *fleet.Host) {
|
||
serial, err := scepDepot.Serial()
|
||
require.NoError(t, err)
|
||
cert := &x509.Certificate{
|
||
SerialNumber: serial,
|
||
Subject: pkix.Name{
|
||
CommonName: "Fleet Identity",
|
||
},
|
||
NotBefore: time.Now().Add(-24 * time.Hour),
|
||
NotAfter: notAfter,
|
||
// use a random value, just to make sure they're
|
||
// different from each other, we don't care about the
|
||
// DER contents here
|
||
Raw: []byte(uuid.NewString()),
|
||
}
|
||
err = scepDepot.Put(cert.Subject.CommonName, cert)
|
||
require.NoError(t, err)
|
||
req := mdm.Request{
|
||
EnrollID: &mdm.EnrollID{ID: h.UUID},
|
||
Context: ctx,
|
||
}
|
||
certHash := certauth.HashCert(cert)
|
||
err = nanoStorage.AssociateCertHash(&req, certHash, notAfter)
|
||
require.NoError(t, err)
|
||
createdat := time.Now().AddDate(0, int(serial.Int64()), 0)
|
||
|
||
// due to mysql timestamp resolution, this test is flaky unless
|
||
// we do this because we insert multiple certificates for the
|
||
// same device in quick succession, and later on we assert on a
|
||
// specific order which is based on created_at
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
UPDATE nano_cert_auth_associations
|
||
SET created_at = ?
|
||
WHERE sha256 = ?
|
||
`, createdat, certHash)
|
||
return err
|
||
})
|
||
}
|
||
|
||
var i int
|
||
setHost := func(notAfter time.Time, doUserDeviceEnrollment bool) *fleet.Host {
|
||
i++
|
||
h, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: fmt.Sprintf("test-host%d-name", i),
|
||
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
|
||
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
|
||
UUID: fmt.Sprintf("test-uuid-%d", i),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// create a cert + association
|
||
addCert(notAfter, h)
|
||
if doUserDeviceEnrollment {
|
||
nanoEnrollUserDevice(t, ds, h)
|
||
} else {
|
||
nanoEnroll(t, ds, h, true)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// certs expired at lest 1 year ago
|
||
h1 := setHost(time.Now().AddDate(-1, -3, 0), false)
|
||
h2 := setHost(time.Now().AddDate(-1, -2, 0), false)
|
||
// cert that expires in 1 month
|
||
h3 := setHost(time.Now().AddDate(0, 1, 0), false)
|
||
// User Enrollment (Device) cert that expires in 1 month and 1 day just
|
||
// so we can add some assertions on the returned enrollment type
|
||
h4 := setHost(time.Now().AddDate(0, 1, 1), true)
|
||
// cert that expires in 1 year
|
||
h5 := setHost(time.Now().AddDate(1, 0, 0), false)
|
||
// expired cert for a host migrated using touchless migration
|
||
hMigrated := setHost(time.Now().AddDate(-1, -1, 0), false)
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
UPDATE nano_enrollments
|
||
SET enrolled_from_migration = 1
|
||
WHERE id = ?
|
||
`, hMigrated.UUID)
|
||
return err
|
||
})
|
||
|
||
// list assocs that expire in the next 10 days
|
||
assocs, err := ds.GetHostCertAssociationsToExpire(ctx, 10, 100)
|
||
require.NoError(t, err)
|
||
require.Len(t, assocs, 3)
|
||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||
require.Equal(t, hMigrated.UUID, assocs[2].HostUUID)
|
||
require.True(t, assocs[2].EnrolledFromMigration)
|
||
|
||
// list certs that expire in the next 1000 days with limit = 1
|
||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 1)
|
||
require.NoError(t, err)
|
||
require.Len(t, assocs, 1)
|
||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||
|
||
// list certs that expire in the next 50 days
|
||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 50, 100)
|
||
require.NoError(t, err)
|
||
require.Len(t, assocs, 5)
|
||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||
assert.Equal(t, "Device", assocs[0].EnrollmentType)
|
||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||
assert.Equal(t, "Device", assocs[1].EnrollmentType)
|
||
require.Equal(t, hMigrated.UUID, assocs[2].HostUUID)
|
||
assert.Equal(t, "Device", assocs[2].EnrollmentType)
|
||
require.Equal(t, h3.UUID, assocs[3].HostUUID)
|
||
assert.Equal(t, "Device", assocs[3].EnrollmentType)
|
||
require.Equal(t, h4.UUID, assocs[4].HostUUID)
|
||
assert.Equal(t, "User Enrollment (Device)", assocs[4].EnrollmentType)
|
||
|
||
// list certs that expire in the next 1000 days
|
||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 100)
|
||
require.NoError(t, err)
|
||
require.Len(t, assocs, 6)
|
||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||
assert.Equal(t, "Device", assocs[0].EnrollmentType)
|
||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||
assert.Equal(t, "Device", assocs[1].EnrollmentType)
|
||
require.Equal(t, hMigrated.UUID, assocs[2].HostUUID)
|
||
assert.Equal(t, "Device", assocs[2].EnrollmentType)
|
||
require.Equal(t, h3.UUID, assocs[3].HostUUID)
|
||
assert.Equal(t, "Device", assocs[3].EnrollmentType)
|
||
require.Equal(t, h4.UUID, assocs[4].HostUUID)
|
||
assert.Equal(t, "User Enrollment (Device)", assocs[4].EnrollmentType)
|
||
require.Equal(t, h5.UUID, assocs[5].HostUUID)
|
||
assert.Equal(t, "Device", assocs[5].EnrollmentType)
|
||
|
||
// add a new host with a very old expiriy so it shows first, verify
|
||
// that it's present before deleting it.
|
||
h6 := setHost(time.Now().AddDate(-2, -1, 0), false)
|
||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 100)
|
||
require.NoError(t, err)
|
||
require.Len(t, assocs, 7)
|
||
require.Equal(t, h6.UUID, assocs[0].HostUUID)
|
||
require.Equal(t, h1.UUID, assocs[1].HostUUID)
|
||
require.Equal(t, h2.UUID, assocs[2].HostUUID)
|
||
require.Equal(t, hMigrated.UUID, assocs[3].HostUUID)
|
||
require.Equal(t, h3.UUID, assocs[4].HostUUID)
|
||
require.Equal(t, h4.UUID, assocs[5].HostUUID)
|
||
require.Equal(t, h5.UUID, assocs[6].HostUUID)
|
||
|
||
// delete the host and verify that things work as expected
|
||
// see https://github.com/fleetdm/fleet/issues/19149
|
||
require.NoError(t, ds.DeleteHost(ctx, h6.ID))
|
||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 100)
|
||
require.NoError(t, err)
|
||
require.Len(t, assocs, 6)
|
||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||
require.Equal(t, hMigrated.UUID, assocs[2].HostUUID)
|
||
require.Equal(t, h3.UUID, assocs[3].HostUUID)
|
||
require.Equal(t, h4.UUID, assocs[4].HostUUID)
|
||
require.Equal(t, h5.UUID, assocs[5].HostUUID)
|
||
|
||
// add a second expired cert to one of the hosts
|
||
addCert(time.Now().AddDate(-1, 0, 0), h1)
|
||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 100)
|
||
require.Len(t, assocs, 6)
|
||
require.Equal(t, h2.UUID, assocs[0].HostUUID)
|
||
require.Equal(t, hMigrated.UUID, assocs[1].HostUUID)
|
||
require.Equal(t, h1.UUID, assocs[2].HostUUID)
|
||
require.Equal(t, h3.UUID, assocs[3].HostUUID)
|
||
require.Equal(t, h4.UUID, assocs[4].HostUUID)
|
||
require.Equal(t, h5.UUID, assocs[5].HostUUID)
|
||
|
||
checkSCEPRenew := func(assoc fleet.SCEPIdentityAssociation, want *string) {
|
||
var got *string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(
|
||
ctx,
|
||
q,
|
||
&got,
|
||
`SELECT renew_command_uuid FROM nano_cert_auth_associations WHERE id = ? AND sha256 = ?`,
|
||
assoc.HostUUID,
|
||
assoc.SHA256,
|
||
)
|
||
})
|
||
require.EqualValues(t, want, got)
|
||
}
|
||
|
||
// insert dummy nano commands
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err = q.ExecContext(ctx, `
|
||
INSERT INTO nano_commands (command_uuid, request_type, command)
|
||
VALUES ('foo', 'foo', '<?xml'), ('bar', 'bar', '<?xml')
|
||
`)
|
||
return err
|
||
})
|
||
|
||
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{}, "foo")
|
||
checkSCEPRenew(assocs[0], nil)
|
||
checkSCEPRenew(assocs[1], nil)
|
||
checkSCEPRenew(assocs[2], nil)
|
||
checkSCEPRenew(assocs[3], nil)
|
||
checkSCEPRenew(assocs[4], nil)
|
||
checkSCEPRenew(assocs[5], nil)
|
||
require.NoError(t, err)
|
||
|
||
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{assocs[0]}, "foo")
|
||
require.NoError(t, err)
|
||
checkSCEPRenew(assocs[0], ptr.String("foo"))
|
||
checkSCEPRenew(assocs[1], nil)
|
||
checkSCEPRenew(assocs[2], nil)
|
||
checkSCEPRenew(assocs[3], nil)
|
||
checkSCEPRenew(assocs[4], nil)
|
||
checkSCEPRenew(assocs[5], nil)
|
||
|
||
err = ds.SetCommandForPendingSCEPRenewal(ctx, assocs, "bar")
|
||
require.NoError(t, err)
|
||
checkSCEPRenew(assocs[0], ptr.String("bar"))
|
||
checkSCEPRenew(assocs[1], ptr.String("bar"))
|
||
checkSCEPRenew(assocs[2], ptr.String("bar"))
|
||
checkSCEPRenew(assocs[3], ptr.String("bar"))
|
||
checkSCEPRenew(assocs[4], ptr.String("bar"))
|
||
checkSCEPRenew(assocs[5], ptr.String("bar"))
|
||
|
||
err = ds.SetCommandForPendingSCEPRenewal(
|
||
ctx,
|
||
[]fleet.SCEPIdentityAssociation{{HostUUID: "foo", SHA256: "bar"}},
|
||
"bar",
|
||
)
|
||
require.ErrorContains(t, err, "this function can only be used to update existing associations")
|
||
|
||
err = ds.CleanSCEPRenewRefs(ctx, "does-not-exist")
|
||
require.Error(t, err)
|
||
|
||
err = ds.CleanSCEPRenewRefs(ctx, h1.UUID)
|
||
require.NoError(t, err)
|
||
checkSCEPRenew(assocs[2], nil)
|
||
}
|
||
|
||
func testMDMProfilesSummaryAndHostFilters(t *testing.T, ds *Datastore) {
|
||
// TODO: Expand this test to include:
|
||
// - more scenarios for windows
|
||
// - disk encryption (mac and windows)
|
||
// - more scenarios for labels
|
||
|
||
ctx := context.Background()
|
||
|
||
checkSummaryWindows := func(t *testing.T, teamID *uint, expected fleet.MDMProfilesSummary) {
|
||
// GetMDMWindowsProfilesSummary reads the maintained host_mdm_windows_profiles_status rollup; this test seeds
|
||
// host_mdm_windows_profiles directly, so reconcile it first.
|
||
require.NoError(t, ds.ReconcileWindowsProfilesStatus(ctx))
|
||
ps, err := ds.GetMDMWindowsProfilesSummary(ctx, teamID)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, ps)
|
||
require.Equal(t, expected, *ps)
|
||
}
|
||
|
||
checkSummaryMac := func(t *testing.T, teamID *uint, expected fleet.MDMProfilesSummary) {
|
||
ps, err := ds.GetMDMAppleProfilesSummary(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 _, h := range gotHosts {
|
||
gotIDs = append(gotIDs, 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) {
|
||
expectSummaryWindows := map[fleet.MDMDeliveryStatus]uint{}
|
||
expectSummaryMac := map[fleet.MDMDeliveryStatus]uint{}
|
||
for status, ids := range ep {
|
||
if len(ids) > 0 {
|
||
for _, id := range ids {
|
||
if id < 5 {
|
||
expectSummaryWindows[status]++
|
||
} else {
|
||
expectSummaryMac[status]++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
checkSummaryMac(t, teamID, fleet.MDMProfilesSummary{
|
||
Pending: expectSummaryMac[fleet.MDMDeliveryPending],
|
||
Failed: expectSummaryMac[fleet.MDMDeliveryFailed],
|
||
Verifying: expectSummaryMac[fleet.MDMDeliveryVerifying],
|
||
Verified: expectSummaryMac[fleet.MDMDeliveryVerified],
|
||
})
|
||
|
||
checkSummaryWindows(t, teamID, fleet.MDMProfilesSummary{
|
||
Pending: expectSummaryWindows[fleet.MDMDeliveryPending],
|
||
Failed: expectSummaryWindows[fleet.MDMDeliveryFailed],
|
||
Verifying: expectSummaryWindows[fleet.MDMDeliveryVerifying],
|
||
Verified: expectSummaryWindows[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],
|
||
)
|
||
}
|
||
|
||
// checkWinHostProfiles := func(t *testing.T, hostUUID string, statusByProfUUID map[string]string) {
|
||
// profs, err := ds.GetHostMDMWindowsProfiles(ctx, hostUUID)
|
||
// require.NoError(t, err)
|
||
// require.Len(t, profs, len(statusByProfUUID))
|
||
// for _, prof := range profs {
|
||
// ep, ok := statusByProfUUID[prof.ProfileUUID]
|
||
// require.True(t, ok)
|
||
// require.Equal(t, ep, prof.Status)
|
||
// }
|
||
// }
|
||
|
||
checkMacHostProfiles := func(t *testing.T, hostUUID string, statusByProfUUID map[string]string) {
|
||
profs, err := ds.GetHostMDMAppleProfiles(ctx, hostUUID)
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, len(statusByProfUUID))
|
||
for _, prof := range profs {
|
||
ep, ok := statusByProfUUID[prof.ProfileUUID]
|
||
require.True(t, ok)
|
||
require.NotNil(t, prof.Status)
|
||
require.Equal(t, fleet.MDMDeliveryStatus(ep), *prof.Status)
|
||
}
|
||
}
|
||
|
||
upsertHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, status *fleet.MDMDeliveryStatus) {
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
var table string
|
||
var profType string
|
||
switch {
|
||
case strings.HasPrefix(profUUID, "a"):
|
||
table = "host_mdm_apple_profiles"
|
||
profType = "profile"
|
||
case strings.HasPrefix(profUUID, "w"):
|
||
table = "host_mdm_windows_profiles"
|
||
profType = "profile"
|
||
case strings.HasPrefix(profUUID, "d"):
|
||
table = "host_mdm_apple_declarations"
|
||
profType = "declaration"
|
||
default:
|
||
require.FailNow(t, "unknown profile type")
|
||
}
|
||
|
||
var stmt string
|
||
var err error
|
||
switch {
|
||
case table == "host_mdm_apple_profiles":
|
||
// Apple profiles require profile_identifier, checksum, and command_uuid
|
||
commandUUID := "cmd-" + profUUID
|
||
stmt = fmt.Sprintf(
|
||
`INSERT INTO %s (host_uuid, %s_uuid, status, profile_identifier, checksum, command_uuid) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`,
|
||
table,
|
||
profType,
|
||
)
|
||
testChecksum := test.MakeTestBytes() // 16 bytes for checksum
|
||
_, err = q.ExecContext(ctx, stmt, hostUUID, profUUID, status, "com.test."+profUUID, testChecksum, commandUUID, status)
|
||
case table == "host_mdm_apple_declarations":
|
||
// Apple declarations require declaration_identifier and token
|
||
testToken := test.MakeTestBytes() // 16 bytes for token
|
||
stmt = fmt.Sprintf(
|
||
`INSERT INTO %s (host_uuid, %s_uuid, status, declaration_identifier, token) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`,
|
||
table,
|
||
profType,
|
||
)
|
||
_, err = q.ExecContext(ctx, stmt, hostUUID, profUUID, status, "com.test."+profUUID, testToken, status)
|
||
case strings.HasPrefix(profUUID, "w"):
|
||
// Windows profiles require command_uuid
|
||
commandUUID := "cmd-" + profUUID
|
||
stmt = fmt.Sprintf(
|
||
`INSERT INTO %s (host_uuid, %s_uuid, status, command_uuid) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`,
|
||
table,
|
||
profType,
|
||
)
|
||
_, err = q.ExecContext(ctx, stmt, hostUUID, profUUID, status, commandUUID, status)
|
||
default:
|
||
stmt = fmt.Sprintf(
|
||
`INSERT INTO %s (host_uuid, %s_uuid, status) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`,
|
||
table,
|
||
profType,
|
||
)
|
||
_, err = q.ExecContext(ctx, stmt, hostUUID, profUUID, status, status)
|
||
}
|
||
if err != nil {
|
||
require.NoError(t, err)
|
||
return err
|
||
}
|
||
stmt = fmt.Sprintf(
|
||
`UPDATE %s SET operation_type = ? WHERE host_uuid = ? AND %s_uuid = ?`,
|
||
table,
|
||
profType,
|
||
)
|
||
_, err = q.ExecContext(ctx, stmt, fleet.MDMOperationTypeInstall, hostUUID, profUUID)
|
||
require.NoError(t, err)
|
||
return err
|
||
})
|
||
}
|
||
|
||
cleanupTables := func(t *testing.T) {
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_windows_profiles`)
|
||
return err
|
||
})
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_apple_profiles`)
|
||
return err
|
||
})
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_apple_declarations`)
|
||
return err
|
||
})
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_disk_encryption_keys`)
|
||
return err
|
||
})
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_disks`)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// updateHostDisks := func(t *testing.T, hostID uint, encrypted bool, updated_at time.Time) {
|
||
// ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
// stmt := `UPDATE host_disks SET encrypted = ?, updated_at = ? where host_id = ?`
|
||
// _, err := q.ExecContext(ctx, stmt, encrypted, updated_at, hostID)
|
||
// return err
|
||
// })
|
||
// }
|
||
|
||
// Create some hosts
|
||
var hosts []*fleet.Host
|
||
macHostsByID := make(map[uint]*fleet.Host, 5)
|
||
winHostsByID := make(map[uint]*fleet.Host, 5)
|
||
for i := 0; i < 10; i++ {
|
||
p := "windows"
|
||
if i >= 5 {
|
||
p = "darwin"
|
||
}
|
||
u := uuid.New().String()
|
||
h, err := ds.NewHost(ctx, &fleet.Host{
|
||
DetailUpdatedAt: time.Now(),
|
||
LabelUpdatedAt: time.Now(),
|
||
PolicyUpdatedAt: time.Now(),
|
||
SeenTime: time.Now(),
|
||
NodeKey: &u,
|
||
UUID: u,
|
||
Hostname: u,
|
||
Platform: p,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, h)
|
||
hosts = append(hosts, h)
|
||
if p == "darwin" {
|
||
nanoEnroll(t, ds, h, false)
|
||
macHostsByID[h.ID] = h
|
||
} else {
|
||
winHostsByID[h.ID] = h
|
||
windowsEnrollment := &fleet.MDMWindowsEnrolledDevice{
|
||
MDMDeviceID: uuid.New().String(),
|
||
MDMHardwareID: uuid.New().String() + uuid.New().String(),
|
||
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
||
MDMDeviceType: "CIMClient_Windows",
|
||
MDMDeviceName: "DESKTOP-1C3ARC1",
|
||
MDMEnrollType: "ProgrammaticEnrollment",
|
||
MDMEnrollUserID: "",
|
||
MDMEnrollProtoVersion: "5.0",
|
||
MDMEnrollClientVersion: "10.0.19045.2965",
|
||
MDMNotInOOBE: false,
|
||
HostUUID: h.UUID,
|
||
}
|
||
err = ds.MDMWindowsInsertEnrolledDevice(ctx, windowsEnrollment)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
require.NoError(
|
||
t,
|
||
ds.SetOrUpdateMDMData(
|
||
ctx,
|
||
h.ID,
|
||
false,
|
||
true,
|
||
"https://example.com",
|
||
false,
|
||
fleet.WellKnownMDMFleet,
|
||
"",
|
||
false,
|
||
),
|
||
)
|
||
}
|
||
|
||
checkExpected(t, nil, nil)
|
||
|
||
upsertHostProfileStatus(t, hosts[0].UUID, "w1", &fleet.MDMDeliveryPending)
|
||
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
})
|
||
|
||
// add some mac profiles with different statuses
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a1", &fleet.MDMDeliveryFailed)
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a2", &fleet.MDMDeliveryPending)
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a3", &fleet.MDMDeliveryVerifying)
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a4", &fleet.MDMDeliveryVerified)
|
||
|
||
// add some mac declarations with different statuses
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d1", &fleet.MDMDeliveryFailed)
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d2", &fleet.MDMDeliveryPending)
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d3", &fleet.MDMDeliveryVerifying)
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d4", &fleet.MDMDeliveryVerified)
|
||
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryFailed: []uint{hosts[9].ID},
|
||
})
|
||
expectedHostProfiles := map[string]string{
|
||
"a1": "failed",
|
||
"a2": "pending",
|
||
"a3": "verifying",
|
||
"a4": "verified",
|
||
"d1": "failed",
|
||
"d2": "pending",
|
||
"d3": "verifying",
|
||
"d4": "verified",
|
||
}
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set failed mac profile to pending, still failed because of failed declaration
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a1", &fleet.MDMDeliveryPending)
|
||
expectedHostProfiles["a1"] = "pending"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryFailed: []uint{hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set failed mac declaration to pending, now host stsatus is pending
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d1", &fleet.MDMDeliveryPending)
|
||
expectedHostProfiles["d1"] = "pending"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set pending mac declaration to failed, host status is now failed
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d2", &fleet.MDMDeliveryFailed)
|
||
expectedHostProfiles["d2"] = "failed"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryFailed: []uint{hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set failed mac declaration to verifying, host status is now pending
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d2", &fleet.MDMDeliveryVerifying)
|
||
expectedHostProfiles["d2"] = "verifying"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set pending mac profiles to verifying, host status is still pending because d1 is still pending
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a1", &fleet.MDMDeliveryVerifying)
|
||
expectedHostProfiles["a1"] = "verifying"
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a2", &fleet.MDMDeliveryVerifying)
|
||
expectedHostProfiles["a2"] = "verifying"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set pending mac declarations to verifying, host status is now verifying
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d1", &fleet.MDMDeliveryVerifying)
|
||
expectedHostProfiles["d1"] = "verifying"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryVerifying: []uint{hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set a mac profile to failed, host status is now failed
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a1", &fleet.MDMDeliveryFailed)
|
||
expectedHostProfiles["a1"] = "failed"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryFailed: []uint{hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set mac profiles to verified, host status is now verifying because declarations are still
|
||
// verifying
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a1", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["a1"] = "verified"
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a2", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["a2"] = "verified"
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a3", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["a3"] = "verified"
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "a4", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["a4"] = "verified"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryVerifying: []uint{hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set mac declarations to verified, host status is now verified
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d1", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["d1"] = "verified"
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d2", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["d2"] = "verified"
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d3", &fleet.MDMDeliveryVerified)
|
||
expectedHostProfiles["d3"] = "verified"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryVerified: []uint{hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// set a mac declaration to nil, host status is now pending
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d1", nil)
|
||
expectedHostProfiles["d1"] = "pending"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// works as expected if we remove mac declarations
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_apple_declarations`)
|
||
return err
|
||
})
|
||
delete(expectedHostProfiles, "d1")
|
||
delete(expectedHostProfiles, "d2")
|
||
delete(expectedHostProfiles, "d3")
|
||
delete(expectedHostProfiles, "d4")
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
fleet.MDMDeliveryVerified: []uint{hosts[9].ID}, // all profiles were verified
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// works as expected if we remove mac profiles
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_apple_profiles`)
|
||
return err
|
||
})
|
||
delete(expectedHostProfiles, "a1")
|
||
delete(expectedHostProfiles, "a2")
|
||
delete(expectedHostProfiles, "a3")
|
||
delete(expectedHostProfiles, "a4")
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
// works as expected if declarations but no profiles
|
||
upsertHostProfileStatus(t, hosts[9].UUID, "d1", &fleet.MDMDeliveryPending)
|
||
expectedHostProfiles["d1"] = "pending"
|
||
checkExpected(t, nil, hostIDsByProfileStatus{
|
||
fleet.MDMDeliveryPending: []uint{hosts[0].ID, hosts[9].ID},
|
||
})
|
||
checkMacHostProfiles(t, hosts[9].UUID, expectedHostProfiles)
|
||
|
||
cleanupTables(t)
|
||
}
|
||
|
||
func testAreHostsConnectedToFleetMDM(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
notConnectedMac, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test",
|
||
OsqueryHostID: ptr.String("osquery-macos-not-connected"),
|
||
NodeKey: ptr.String("node-key-macos-not-connected"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
connectedMac, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test",
|
||
OsqueryHostID: ptr.String("osquery-macos"),
|
||
NodeKey: ptr.String("node-key-macos"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, connectedMac, false)
|
||
err = ds.SetOrUpdateMDMData(ctx, connectedMac.ID, false, true, "http://foo.com", false, "foo", "", false)
|
||
require.NoError(t, err)
|
||
|
||
disconnectedWithoutCheckoutMac, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test-disconnected",
|
||
OsqueryHostID: ptr.String("osquery-macos-disconnected"),
|
||
NodeKey: ptr.String("node-key-macos-disconnected"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, disconnectedWithoutCheckoutMac, false)
|
||
err = ds.SetOrUpdateMDMData(ctx, disconnectedWithoutCheckoutMac.ID, false, false, "", false, "", "", false)
|
||
require.NoError(t, err)
|
||
|
||
notConnectedWin, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "windows-test",
|
||
OsqueryHostID: ptr.String("osquery-windows-not-connected"),
|
||
NodeKey: ptr.String("node-key-windows-not-connected"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "windows",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
connectedWin, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "windows-test",
|
||
OsqueryHostID: ptr.String("osquery-windows"),
|
||
NodeKey: ptr.String("node-key-windows"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "windows",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
windowsEnrollment := &fleet.MDMWindowsEnrolledDevice{
|
||
MDMDeviceID: uuid.New().String(),
|
||
MDMHardwareID: uuid.New().String() + uuid.New().String(),
|
||
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
||
MDMDeviceType: "CIMClient_Windows",
|
||
MDMDeviceName: "DESKTOP-1C3ARC1",
|
||
MDMEnrollType: "ProgrammaticEnrollment",
|
||
MDMEnrollUserID: "",
|
||
MDMEnrollProtoVersion: "5.0",
|
||
MDMEnrollClientVersion: "10.0.19045.2965",
|
||
MDMNotInOOBE: false,
|
||
HostUUID: connectedWin.UUID,
|
||
}
|
||
err = ds.MDMWindowsInsertEnrolledDevice(ctx, windowsEnrollment)
|
||
require.NoError(t, err)
|
||
err = ds.SetOrUpdateMDMData(ctx, connectedWin.ID, false, true, "http://foo.com", false, "foo", "", false)
|
||
require.NoError(t, err)
|
||
|
||
disconnectedWithoutCheckoutWin, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "windows-test-disconnected",
|
||
OsqueryHostID: ptr.String("osquery-windows-disconnected"),
|
||
NodeKey: ptr.String("node-key-windows-disconnected"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "windows",
|
||
})
|
||
require.NoError(t, err)
|
||
windowsEnrollmentDisconnectedWithoutCheckout := &fleet.MDMWindowsEnrolledDevice{
|
||
MDMDeviceID: uuid.New().String(),
|
||
MDMHardwareID: uuid.New().String() + uuid.New().String(),
|
||
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
||
MDMDeviceType: "CIMClient_Windows",
|
||
MDMDeviceName: "DESKTOP-1C3ARC1-disconnected",
|
||
MDMEnrollType: "ProgrammaticEnrollment",
|
||
MDMEnrollUserID: "",
|
||
MDMEnrollProtoVersion: "5.0",
|
||
MDMEnrollClientVersion: "10.0.19045.2965",
|
||
MDMNotInOOBE: false,
|
||
HostUUID: disconnectedWithoutCheckoutWin.UUID,
|
||
}
|
||
err = ds.MDMWindowsInsertEnrolledDevice(ctx, windowsEnrollmentDisconnectedWithoutCheckout)
|
||
require.NoError(t, err)
|
||
err = ds.SetOrUpdateMDMData(ctx, disconnectedWithoutCheckoutWin.ID, false, false, "", false, "", "", false)
|
||
require.NoError(t, err)
|
||
|
||
connectedMap, err := ds.AreHostsConnectedToFleetMDM(ctx, []*fleet.Host{
|
||
notConnectedMac,
|
||
connectedMac,
|
||
connectedWin,
|
||
notConnectedWin,
|
||
disconnectedWithoutCheckoutMac,
|
||
disconnectedWithoutCheckoutWin,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, map[string]bool{
|
||
notConnectedMac.UUID: false,
|
||
connectedMac.UUID: true,
|
||
connectedWin.UUID: true,
|
||
notConnectedWin.UUID: false,
|
||
disconnectedWithoutCheckoutMac.UUID: false,
|
||
disconnectedWithoutCheckoutWin.UUID: false,
|
||
}, connectedMap)
|
||
|
||
linuxHost, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "linux-test",
|
||
OsqueryHostID: ptr.String("osquery-linux"),
|
||
NodeKey: ptr.String("node-key-linux"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "linux",
|
||
})
|
||
require.NoError(t, err)
|
||
connectedMap, err = ds.AreHostsConnectedToFleetMDM(ctx, []*fleet.Host{
|
||
notConnectedMac,
|
||
connectedMac,
|
||
connectedWin,
|
||
notConnectedWin,
|
||
linuxHost,
|
||
disconnectedWithoutCheckoutMac,
|
||
disconnectedWithoutCheckoutWin,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, map[string]bool{
|
||
notConnectedMac.UUID: false,
|
||
connectedMac.UUID: true,
|
||
connectedWin.UUID: true,
|
||
notConnectedWin.UUID: false,
|
||
linuxHost.UUID: false,
|
||
disconnectedWithoutCheckoutMac.UUID: false,
|
||
disconnectedWithoutCheckoutWin.UUID: false,
|
||
}, connectedMap)
|
||
}
|
||
|
||
func testIsHostConnectedToFleetMDM(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
// requireConnected asserts that IsHostConnectedToFleetMDM and the connected_to_fleet flag computed by GetHostMDM agree with the
|
||
// expected value. GetOrbitConfig derives the connection state from GetHostMDM instead of a separate IsHostConnectedToFleetMDM
|
||
// query, so the two must stay in lockstep across every enrollment state. When the host has no host_mdm row, GetHostMDM returns
|
||
// NotFound and the host cannot be connected.
|
||
requireConnected := func(t *testing.T, h *fleet.Host, want bool) {
|
||
t.Helper()
|
||
connected, err := ds.IsHostConnectedToFleetMDM(ctx, h)
|
||
require.NoError(t, err)
|
||
require.Equal(t, want, connected)
|
||
|
||
mdmInfo, err := ds.GetHostMDM(ctx, h.ID)
|
||
if err != nil {
|
||
require.True(t, fleet.IsNotFound(err))
|
||
require.False(t, want, "host without a host_mdm row cannot be connected to Fleet MDM")
|
||
return
|
||
}
|
||
require.Equal(t, want, mdmInfo.ConnectedToFleet)
|
||
}
|
||
|
||
macH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "macos-test",
|
||
OsqueryHostID: ptr.String("osquery-macos"),
|
||
NodeKey: ptr.String("node-key-macos"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, macH, false)
|
||
|
||
nanoEnroll(t, ds, macH, false)
|
||
err = ds.SetOrUpdateMDMData(ctx, macH.ID, false, true, "http://foo.com", false, "foo", "", false)
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, macH, true)
|
||
|
||
byodIpadH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "ipados-test",
|
||
OsqueryHostID: ptr.String("osquery-ipados"),
|
||
NodeKey: ptr.String("node-key-ipados"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "ipados",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
nanoEnrollUserDevice(t, ds, byodIpadH)
|
||
err = ds.SetOrUpdateMDMData(ctx, byodIpadH.ID, false, true, "http://foo.com", false, "foo", "", false)
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, byodIpadH, true)
|
||
|
||
windowsH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "windows-test",
|
||
OsqueryHostID: ptr.String("osquery-windows"),
|
||
NodeKey: ptr.String("node-key-windows"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "windows",
|
||
})
|
||
require.NoError(t, err)
|
||
requireConnected(t, windowsH, false)
|
||
|
||
windowsEnrollment := &fleet.MDMWindowsEnrolledDevice{
|
||
MDMDeviceID: uuid.New().String(),
|
||
MDMHardwareID: uuid.New().String() + uuid.New().String(),
|
||
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
|
||
MDMDeviceType: "CIMClient_Windows",
|
||
MDMDeviceName: "DESKTOP-1C3ARC1",
|
||
MDMEnrollType: "ProgrammaticEnrollment",
|
||
MDMEnrollUserID: "",
|
||
MDMEnrollProtoVersion: "5.0",
|
||
MDMEnrollClientVersion: "10.0.19045.2965",
|
||
MDMNotInOOBE: false,
|
||
HostUUID: windowsH.UUID,
|
||
}
|
||
err = ds.MDMWindowsInsertEnrolledDevice(ctx, windowsEnrollment)
|
||
require.NoError(t, err)
|
||
err = ds.SetOrUpdateMDMData(ctx, windowsH.ID, false, true, "http://foo.com", false, "foo", "", false)
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, windowsH, true)
|
||
|
||
// now simulate an un-enrollment without checkout, in this case, osquery reports the host as not-enrolled
|
||
err = ds.SetOrUpdateMDMData(ctx, macH.ID, false, false, "", false, "", "", false)
|
||
require.NoError(t, err)
|
||
err = ds.SetOrUpdateMDMData(ctx, windowsH.ID, false, false, "", false, "", "", false)
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, macH, false)
|
||
requireConnected(t, windowsH, false)
|
||
|
||
// Simulate the ipad checking out(user removing work account)
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `UPDATE nano_enrollments SET enabled = 0 WHERE id = ?`, byodIpadH.UUID)
|
||
return err
|
||
})
|
||
|
||
requireConnected(t, byodIpadH, false)
|
||
|
||
// Android: connection is determined solely by host_mdm.enrolled, so the connected_to_fleet column must track enrollment without
|
||
// any separate enrollment record.
|
||
androidH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "android-test",
|
||
OsqueryHostID: new("osquery-android"),
|
||
NodeKey: new("node-key-android"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "android",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, androidH, false)
|
||
|
||
err = ds.SetOrUpdateMDMData(ctx, androidH.ID, false, true, "http://foo.com", false, fleet.WellKnownMDMFleet, "", false)
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, androidH, true)
|
||
|
||
err = ds.SetOrUpdateMDMData(ctx, androidH.ID, false, false, "", false, "", "", false)
|
||
require.NoError(t, err)
|
||
|
||
requireConnected(t, androidH, false)
|
||
}
|
||
|
||
// This test now only covers android, as the other platforms no longer rely on the BulkSetPendingMDMHostProfiles,
|
||
// but rather the reconciler to work out the truth after some runs.
|
||
func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
|
||
test.AddBuiltinLabels(t, ds)
|
||
ctx := context.Background()
|
||
|
||
// create some "exclude" labels
|
||
var labels []*fleet.Label
|
||
for i := 0; i < 8; i++ {
|
||
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-label-" + strconv.Itoa(i), Query: "select 1"})
|
||
require.NoError(t, err)
|
||
labels = append(labels, lbl)
|
||
}
|
||
|
||
androidProfs := []*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "G1", nil, labels[6]),
|
||
}
|
||
|
||
updates, err := ds.BatchSetMDMProfiles(ctx, nil, nil, nil, nil, androidProfs, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AndroidConfigProfile)
|
||
|
||
// must reload them to get the profile/declaration uuid
|
||
getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload {
|
||
// TODO(roberto): the docs says that you can pass a comma separated
|
||
// list of columns to OrderKey, but that doesn't seem to work
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, teamID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
sort.Slice(profs, func(i, j int) bool {
|
||
l, r := profs[i], profs[j]
|
||
if l.Platform != r.Platform {
|
||
return l.Platform < r.Platform
|
||
}
|
||
|
||
return l.Name < r.Name
|
||
})
|
||
return profs
|
||
}
|
||
allProfs := getProfs(nil)
|
||
|
||
var i int
|
||
androidHostObj := createAndroidHost(fmt.Sprintf("android-host%d-name", i))
|
||
androidHostObj, err = ds.NewAndroidHost(ctx, androidHostObj, false)
|
||
androidHost := androidHostObj.Host
|
||
require.NoError(t, err)
|
||
|
||
// Set LabelUpdatedAt to a time before labels were created to simulate hosts that haven't reported label membership
|
||
// Use a time in the past (2020) to ensure it's before any label created in this test
|
||
oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
androidHost.LabelUpdatedAt = oldTime
|
||
err = ds.UpdateHost(ctx, androidHost)
|
||
require.NoError(t, err)
|
||
|
||
// at this point the hosts have not reported any label results, so a sync
|
||
// does NOT install the exclude any profiles as we don't know yet if the
|
||
// hosts will be members or not
|
||
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{androidHost.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.False(t, updates.AppleConfigProfile)
|
||
assert.False(t, updates.WindowsConfigProfile)
|
||
assert.False(t, updates.AppleDeclaration)
|
||
assert.False(t, updates.AndroidConfigProfile)
|
||
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
|
||
androidHost: {},
|
||
})
|
||
|
||
// setting the LabelsUpdatedAt timestamp means that labels results were reported, so now
|
||
// the profiles will be installed as the hosts are not members of the excluded labels.
|
||
androidHost.LabelUpdatedAt = time.Now()
|
||
err = ds.UpdateHost(ctx, androidHost)
|
||
require.NoError(t, err)
|
||
|
||
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{androidHost.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AndroidConfigProfile)
|
||
|
||
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
|
||
androidHost: {
|
||
{
|
||
ProfileUUID: allProfs[0].ProfileUUID,
|
||
Status: &fleet.MDMDeliveryPending,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
IdentifierOrName: allProfs[0].Name,
|
||
},
|
||
},
|
||
})
|
||
|
||
// make all hosts members of labels[1], [2], [3], [6] so that all profiles are
|
||
// excluded
|
||
err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{
|
||
{labels[1].ID, androidHost.ID},
|
||
{labels[2].ID, androidHost.ID},
|
||
{labels[3].ID, androidHost.ID},
|
||
{labels[6].ID, androidHost.ID},
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{androidHost.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AndroidConfigProfile)
|
||
|
||
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
|
||
androidHost: {
|
||
{
|
||
ProfileUUID: allProfs[0].ProfileUUID,
|
||
Status: &fleet.MDMDeliveryPending,
|
||
OperationType: fleet.MDMOperationTypeRemove,
|
||
IdentifierOrName: allProfs[0].Name,
|
||
},
|
||
},
|
||
})
|
||
|
||
// make apple host member of labels[2], and windows and android host member of [3], which are irrelevant
|
||
// for their platforms' profiles, so they get all profiles
|
||
err = ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{
|
||
{labels[1].ID, androidHost.ID},
|
||
{labels[2].ID, androidHost.ID},
|
||
{labels[6].ID, androidHost.ID},
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{androidHost.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AndroidConfigProfile)
|
||
|
||
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
|
||
androidHost: {
|
||
{
|
||
ProfileUUID: allProfs[0].ProfileUUID,
|
||
Status: &fleet.MDMDeliveryPending,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
IdentifierOrName: allProfs[0].Name,
|
||
},
|
||
},
|
||
})
|
||
|
||
// null label references to break all profiles (FK is now RESTRICT so we can no longer delete referenced labels)
|
||
for _, lbl := range []*fleet.Label{labels[0], labels[2], labels[3], labels[6]} {
|
||
simulateBrokenLabel(t, ds, ctx, lbl.Name)
|
||
}
|
||
|
||
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{androidHost.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AndroidConfigProfile) // "Broken profiles" behave differently for android.
|
||
|
||
// broken profiles do not get reported as "to remove", except for android
|
||
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
|
||
androidHost: {
|
||
{
|
||
ProfileUUID: allProfs[0].ProfileUUID,
|
||
Status: &fleet.MDMDeliveryPending,
|
||
OperationType: fleet.MDMOperationTypeRemove,
|
||
IdentifierOrName: allProfs[0].Name,
|
||
},
|
||
},
|
||
})
|
||
|
||
i++
|
||
androidHostObj2 := createAndroidHost(fmt.Sprintf("android-host%d-name", i))
|
||
androidHostObj2, err = ds.NewAndroidHost(ctx, androidHostObj2, false)
|
||
require.NoError(t, err)
|
||
androidHost2 := androidHostObj2.Host
|
||
|
||
androidHost2.LabelUpdatedAt = time.Now()
|
||
err = ds.UpdateHost(ctx, androidHost2)
|
||
require.NoError(t, err)
|
||
|
||
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{androidHost.ID, androidHost2.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
assert.True(t, updates.AndroidConfigProfile) // True from first host
|
||
|
||
// broken profiles do not get reported as "to install"
|
||
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
|
||
androidHost: {
|
||
{
|
||
ProfileUUID: allProfs[0].ProfileUUID,
|
||
Status: &fleet.MDMDeliveryPending,
|
||
OperationType: fleet.MDMOperationTypeRemove,
|
||
IdentifierOrName: allProfs[0].Name,
|
||
},
|
||
},
|
||
androidHost2: {},
|
||
})
|
||
}
|
||
|
||
// testBulkSetPendingDefersWindowsReconciliation verifies the production
|
||
// behavior of BulkSetPendingMDMHostProfiles: it does not synchronously
|
||
// write host_mdm_windows_profiles (the mdm_windows_profile_manager cron
|
||
// handles that on its next 30s tick), and updates.WindowsConfigProfile
|
||
// stays false here. The accurate "Windows profile changed" signal comes
|
||
// from batchSetMDMWindowsProfilesDB inside BatchSetMDMProfiles, which is
|
||
// the only consumer.
|
||
func testBulkSetPendingDefersWindowsReconciliation(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
// This test verifies the production async path. The eager hook is
|
||
// off by default, so no opt-in is needed here.
|
||
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "defer-windows-recon-test"})
|
||
require.NoError(t, err)
|
||
|
||
windowsProfs := []*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "DW1", "DW1"),
|
||
}
|
||
_, err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, windowsProfs, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
|
||
host := test.NewHost(t, ds, "dw-host", "dw1", "dw1key", "dw-host-uuid", time.Now(),
|
||
test.WithPlatform("windows"), test.WithTeamID(team.ID))
|
||
windowsEnroll(t, ds, host)
|
||
|
||
// Sanity: host_mdm_windows_profiles starts empty for this host.
|
||
before, err := ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
require.Empty(t, before)
|
||
|
||
updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, []uint{host.ID}, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
// Production path leaves WindowsConfigProfile false: the lone consumer
|
||
// of this field (service/mdm.go's BatchSetMDMProfiles flow) ORs it with
|
||
// profUpdates.WindowsConfigProfile from BatchSetMDMProfiles, which is
|
||
// the accurate transactional signal. With the eager hook disabled here
|
||
// to match production, this method does not compute the activity
|
||
// signal itself.
|
||
assert.False(t, updates.WindowsConfigProfile,
|
||
"production path leaves WindowsConfigProfile false; activity is logged by BatchSetMDMProfiles")
|
||
|
||
// host_mdm_windows_profiles must not have been written synchronously.
|
||
// In production the cron will write it on the next tick; in this test
|
||
// nothing else runs and we go straight to the assertion.
|
||
after, err := ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
assert.Empty(t, after,
|
||
"BulkSetPendingMDMHostProfiles must not write host_mdm_windows_profiles synchronously")
|
||
|
||
// The batched reconciler walks all enrolled Windows hosts each pass, so no
|
||
// per-host pending signal is needed for the deferred work to be picked up.
|
||
}
|
||
|
||
func testBatchResendProfileToHosts(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
// create some hosts and some profiles
|
||
host1 := test.NewHost(t, ds, "host1", "1", "h1key", "host1uuid", time.Now())
|
||
host2 := test.NewHost(t, ds, "host2", "2", "h2key", "host2uuid", time.Now())
|
||
host3 := test.NewHost(t, ds, "host3", "3", "h3key", "host3uuid", time.Now())
|
||
host4 := test.NewHost(t, ds, "host4", "4", "h4key", "host4uuid", time.Now())
|
||
|
||
// create a team and make host4 part of that team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||
require.NoError(t, err)
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host4.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// create some profiles , a and b for no team, c for team
|
||
profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("a", "a", 0), nil)
|
||
require.NoError(t, err)
|
||
profB, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("b", "b", 0), nil)
|
||
require.NoError(t, err)
|
||
profC, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("c", "c", team.ID), nil)
|
||
require.NoError(t, err)
|
||
|
||
t.Logf("profA=%s, profB=%s, profC=%s", profA.ProfileUUID, profB.ProfileUUID, profC.ProfileUUID)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID)
|
||
assertHostProfileStatus(t, ds, host2.UUID)
|
||
assertHostProfileStatus(t, ds, host3.UUID)
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// make profile A installed for all no team
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
|
||
forceSetAppleHostProfileStatus(t, ds, host3.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
|
||
|
||
// batch-resend profile A, does not impact any host
|
||
n, err := ds.BatchResendMDMProfileToHosts(ctx, profA.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 0, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying})
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// batch-resend profile B, does not impact any host as it's not delievered to any yet
|
||
n, err = ds.BatchResendMDMProfileToHosts(ctx, profB.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 0, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying})
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// make profile A failed on a couple hosts, verified on the other
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostProfileStatus(t, ds, host3.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
|
||
// batch-resend profile A, impacts host 1 and 2
|
||
n, err = ds.BatchResendMDMProfileToHosts(ctx, profA.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 2, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// batch-resend profile A again, no change as it is already pending on the impacted hosts
|
||
n, err = ds.BatchResendMDMProfileToHosts(ctx, profA.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 0, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// make profile B failed on all hosts
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostProfileStatus(t, ds, host3.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryFailed})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryFailed})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryFailed})
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// batch-resend profile B, all hosts affected
|
||
n, err = ds.BatchResendMDMProfileToHosts(ctx, profB.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 3, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryPending})
|
||
assertHostProfileStatus(t, ds, host4.UUID)
|
||
|
||
// make profile C failed on host 4, other profiles Verified
|
||
forceSetAppleHostProfileStatus(t, ds, host4.UUID, profC, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
forceSetAppleHostProfileStatus(t, ds, host3.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
|
||
// batch-resend profile C, host 4 affected
|
||
n, err = ds.BatchResendMDMProfileToHosts(ctx, profC.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 1, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host4.UUID,
|
||
hostProfileStatus{profC.ProfileUUID, fleet.MDMDeliveryPending})
|
||
|
||
// batch-resend profile C again, no change
|
||
n, err = ds.BatchResendMDMProfileToHosts(ctx, profC.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed})
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 0, n)
|
||
|
||
assertHostProfileStatus(t, ds, host1.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host2.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host3.UUID,
|
||
hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified},
|
||
hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified})
|
||
assertHostProfileStatus(t, ds, host4.UUID,
|
||
hostProfileStatus{profC.ProfileUUID, fleet.MDMDeliveryPending})
|
||
}
|
||
|
||
func testGetMDMConfigProfileStatus(t *testing.T, ds *Datastore) {
|
||
test.AddBuiltinLabels(t, ds)
|
||
ctx := t.Context()
|
||
|
||
// create a team
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||
require.NoError(t, err)
|
||
|
||
// create some Apple and Windows profiles and declaration for No team
|
||
appleProfsNoTm := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "A1", "A1", uuid.NewString()),
|
||
}
|
||
windowsProfsNoTm := []*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W1", "W1"),
|
||
}
|
||
appleDeclsNoTm := []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "{}"),
|
||
}
|
||
// Create an android profile for No Team
|
||
androidProfilesNoTm := []*fleet.MDMAndroidConfigProfile{
|
||
androidProfileForTest("G1"),
|
||
}
|
||
_, err = ds.BatchSetMDMProfiles(ctx, nil, appleProfsNoTm, windowsProfsNoTm, appleDeclsNoTm, androidProfilesNoTm, nil)
|
||
require.NoError(t, err)
|
||
|
||
// create some Apple and Windows profiles and declaration for the team
|
||
appleProfsTm := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "A2", "A2", uuid.NewString()),
|
||
}
|
||
windowsProfsTm := []*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W2", "W2"),
|
||
}
|
||
appleDeclsTm := []*fleet.MDMAppleDeclaration{
|
||
declForTest("D2", "D2", "{}"),
|
||
}
|
||
androidProfilesTm := []*fleet.MDMAndroidConfigProfile{
|
||
androidProfileForTest("G2"),
|
||
}
|
||
androidProfilesTm[0].TeamID = &team.ID
|
||
_, err = ds.BatchSetMDMProfiles(ctx, &team.ID, appleProfsTm, windowsProfsTm, appleDeclsTm, androidProfilesTm, nil)
|
||
require.NoError(t, err)
|
||
|
||
// collect the profiles in a lookup table by name
|
||
profNameToProf := make(map[string]*fleet.MDMConfigProfilePayload)
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
for _, prof := range profs {
|
||
profNameToProf[prof.Name] = prof
|
||
}
|
||
profs, _, err = ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
for _, prof := range profs {
|
||
profNameToProf[prof.Name] = prof
|
||
}
|
||
|
||
// create some hosts, macOS and Windows, No team and Team
|
||
host1 := test.NewHost(t, ds, "host1", "1", "h1key", "host1uuid", time.Now())
|
||
host2 := test.NewHost(t, ds, "host2", "2", "h2key", "host2uuid", time.Now())
|
||
host3 := test.NewHost(t, ds, "host3", "3", "h3key", "host3uuid", time.Now())
|
||
host4 := test.NewHost(t, ds, "host4", "4", "h4key", "host4uuid", time.Now())
|
||
host5 := test.NewHost(t, ds, "host5", "5", "h5key", "host5uuid", time.Now())
|
||
nanoEnroll(t, ds, host1, false)
|
||
nanoEnroll(t, ds, host2, false)
|
||
nanoEnroll(t, ds, host3, false)
|
||
nanoEnroll(t, ds, host4, false)
|
||
nanoEnroll(t, ds, host5, false)
|
||
|
||
host6 := test.NewHost(t, ds, "host6", "6", "h6key", "host6uuid", time.Now())
|
||
host6.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host6)
|
||
require.NoError(t, err)
|
||
windowsEnroll(t, ds, host6)
|
||
|
||
host7 := test.NewHost(t, ds, "host7", "7", "h7key", "host7uuid", time.Now())
|
||
host7.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host7)
|
||
require.NoError(t, err)
|
||
windowsEnroll(t, ds, host7)
|
||
|
||
host8 := test.NewHost(t, ds, "host8", "8", "h8key", "host8uuid", time.Now())
|
||
host8.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host8)
|
||
require.NoError(t, err)
|
||
windowsEnroll(t, ds, host8)
|
||
|
||
androidHost9 := createAndroidHost("enterprise-id-9")
|
||
newHost, err := ds.NewAndroidHost(context.Background(), androidHost9, false)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, newHost)
|
||
host9 := newHost.Host
|
||
|
||
androidHost10 := createAndroidHost("enterprise-id-10")
|
||
newHost, err = ds.NewAndroidHost(context.Background(), androidHost10, false)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, newHost)
|
||
host10 := newHost.Host
|
||
|
||
androidHost11 := createAndroidHost("enterprise-id-11")
|
||
newHost, err = ds.NewAndroidHost(context.Background(), androidHost11, false)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, newHost)
|
||
host11 := newHost.Host
|
||
|
||
for _, h := range []*fleet.Host{host1, host2, host3, host4, host5, host6, host7, host8} {
|
||
err = ds.SetOrUpdateMDMData(ctx, h.ID, false, true, "https://fleetdm.com", false, fleet.WellKnownMDMFleet, "", false)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// host 4, 5, 8, 10, 11 are on team
|
||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host4.ID, host5.ID, host8.ID, host10.ID, host11.ID}))
|
||
require.NoError(t, err)
|
||
_, _, _, _ = host1, host2, host3, host11
|
||
|
||
// currently no status for any profile
|
||
for _, name := range []string{"A1", "W1", "D1", "A2", "W2", "D2", "G1", "G2"} {
|
||
status, err := ds.GetMDMConfigProfileStatus(ctx, profNameToProf[name].ProfileUUID)
|
||
require.NoError(t, err, name)
|
||
require.Equal(t, fleet.MDMConfigProfileStatus{}, status, name)
|
||
}
|
||
// unknown profile with invalid prefix is not found (we don't test unknown
|
||
// profile with valid prefix here as the not found is handled at the service
|
||
// layer - it loads the profile for authorization)
|
||
_, err = ds.GetMDMConfigProfileStatus(ctx, "ZZ-NOT-FOUND")
|
||
require.Error(t, err)
|
||
var nfe fleet.NotFoundError
|
||
require.ErrorAs(t, err, &nfe)
|
||
|
||
cases := []struct {
|
||
desc string
|
||
profileUUID string
|
||
setup func(t *testing.T)
|
||
want fleet.MDMConfigProfileStatus
|
||
}{
|
||
{
|
||
desc: "macOS no team profile A1 all pending/NULL",
|
||
profileUUID: profNameToProf["A1"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
appleA1 := test.ToMDMAppleConfigProfile(profNameToProf["A1"])
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, appleA1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, appleA1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetAppleHostProfileStatus(t, ds, host3.UUID, appleA1, fleet.MDMOperationTypeInstall, "")
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 3},
|
||
},
|
||
{
|
||
desc: "windows no team profile W1 all pending/NULL",
|
||
profileUUID: profNameToProf["W1"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
winW1 := test.ToMDMWindowsConfigProfile(profNameToProf["W1"])
|
||
forceSetWindowsHostProfileStatus(t, ds, host6.UUID, winW1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetWindowsHostProfileStatus(t, ds, host7.UUID, winW1, fleet.MDMOperationTypeInstall, "")
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 2},
|
||
},
|
||
{
|
||
desc: "windows no team profile W1 pending failed",
|
||
profileUUID: profNameToProf["W1"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
winW1 := test.ToMDMWindowsConfigProfile(profNameToProf["W1"])
|
||
forceSetWindowsHostProfileStatus(t, ds, host6.UUID, winW1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetWindowsHostProfileStatus(t, ds, host7.UUID, winW1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 1, Failed: 1},
|
||
},
|
||
{
|
||
desc: "macOS no team decl D1 pending failed verified",
|
||
profileUUID: profNameToProf["D1"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
declD1 := test.ToMDMAppleDecl(profNameToProf["D1"])
|
||
forceSetAppleHostDeclarationStatus(t, ds, host1.UUID, declD1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetAppleHostDeclarationStatus(t, ds, host2.UUID, declD1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostDeclarationStatus(t, ds, host3.UUID, declD1, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 1, Failed: 1, Verified: 1},
|
||
},
|
||
{
|
||
desc: "macOS team profile A2 verifying verified",
|
||
profileUUID: profNameToProf["A2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
appleA2 := test.ToMDMAppleConfigProfile(profNameToProf["A2"])
|
||
forceSetAppleHostProfileStatus(t, ds, host4.UUID, appleA2, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
|
||
forceSetAppleHostProfileStatus(t, ds, host5.UUID, appleA2, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Verifying: 1, Verified: 1},
|
||
},
|
||
{
|
||
desc: "macOS team decl D2 all failed",
|
||
profileUUID: profNameToProf["D2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
declD2 := test.ToMDMAppleDecl(profNameToProf["D2"])
|
||
forceSetAppleHostDeclarationStatus(t, ds, host4.UUID, declD2, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
forceSetAppleHostDeclarationStatus(t, ds, host5.UUID, declD2, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Failed: 2},
|
||
},
|
||
{
|
||
desc: "windows team profile W2 pending",
|
||
profileUUID: profNameToProf["W2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
winW2 := test.ToMDMWindowsConfigProfile(profNameToProf["W2"])
|
||
forceSetWindowsHostProfileStatus(t, ds, host8.UUID, winW2, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 1},
|
||
},
|
||
{
|
||
desc: "android no team G1 profile failed",
|
||
profileUUID: profNameToProf["G1"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
androidG1 := profNameToProf["G1"]
|
||
upsertAndroidHostProfileStatus(t, ds, host9.UUID, androidG1.ProfileUUID, &fleet.MDMDeliveryFailed)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Failed: 1},
|
||
},
|
||
{
|
||
desc: "android team G2 profile nil=pending",
|
||
profileUUID: profNameToProf["G2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
androidG2 := profNameToProf["G2"]
|
||
upsertAndroidHostProfileStatus(t, ds, host10.UUID, androidG2.ProfileUUID, nil)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 1},
|
||
},
|
||
{
|
||
desc: "android team G2 profile nil + pending = 2 pending",
|
||
profileUUID: profNameToProf["G2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
androidG2 := profNameToProf["G2"]
|
||
upsertAndroidHostProfileStatus(t, ds, host10.UUID, androidG2.ProfileUUID, nil)
|
||
upsertAndroidHostProfileStatus(t, ds, host11.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryPending)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 2},
|
||
},
|
||
{
|
||
desc: "android team G2 profile pending + verifying",
|
||
profileUUID: profNameToProf["G2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
androidG2 := profNameToProf["G2"]
|
||
upsertAndroidHostProfileStatus(t, ds, host10.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryVerifying)
|
||
upsertAndroidHostProfileStatus(t, ds, host11.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryPending)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Pending: 1, Verifying: 1},
|
||
},
|
||
{
|
||
desc: "android team G2 profile verified + verifying",
|
||
profileUUID: profNameToProf["G2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
androidG2 := profNameToProf["G2"]
|
||
upsertAndroidHostProfileStatus(t, ds, host10.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryVerified)
|
||
upsertAndroidHostProfileStatus(t, ds, host11.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryVerifying)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Verifying: 1, Verified: 1},
|
||
},
|
||
{
|
||
desc: "android team G2 profile failed + verified",
|
||
profileUUID: profNameToProf["G2"].ProfileUUID,
|
||
setup: func(t *testing.T) {
|
||
// Set an unrelated profile status on another team just to ensure isolation
|
||
androidG1 := profNameToProf["G1"]
|
||
upsertAndroidHostProfileStatus(t, ds, host9.UUID, androidG1.ProfileUUID, &fleet.MDMDeliveryPending)
|
||
|
||
androidG2 := profNameToProf["G2"]
|
||
upsertAndroidHostProfileStatus(t, ds, host10.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryVerified)
|
||
upsertAndroidHostProfileStatus(t, ds, host11.UUID, androidG2.ProfileUUID, &fleet.MDMDeliveryFailed)
|
||
},
|
||
want: fleet.MDMConfigProfileStatus{Failed: 1, Verified: 1},
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.desc, func(t *testing.T) {
|
||
c.setup(t)
|
||
got, err := ds.GetMDMConfigProfileStatus(ctx, c.profileUUID)
|
||
require.NoError(t, err)
|
||
require.Equal(t, c.want, got)
|
||
})
|
||
}
|
||
}
|
||
|
||
// enableWindowsMDMForReconcileTest enables Windows MDM in app config so ReconcileWindowsProfiles does work in tests, restoring the
|
||
// previous value afterward (it is a global flag that would otherwise leak into sibling subtests sharing the datastore).
|
||
func enableWindowsMDMForReconcileTest(ctx context.Context, t *testing.T, ds *Datastore) {
|
||
appCfg, err := ds.AppConfig(ctx)
|
||
require.NoError(t, err)
|
||
prev := appCfg.MDM.WindowsEnabledAndConfigured
|
||
appCfg.MDM.WindowsEnabledAndConfigured = true
|
||
require.NoError(t, ds.SaveAppConfig(ctx, appCfg))
|
||
t.Cleanup(func() {
|
||
bgCtx := context.Background()
|
||
cfg, err := ds.AppConfig(bgCtx)
|
||
require.NoError(t, err)
|
||
cfg.MDM.WindowsEnabledAndConfigured = prev
|
||
require.NoError(t, ds.SaveAppConfig(bgCtx, cfg))
|
||
})
|
||
}
|
||
|
||
func testDeleteMDMProfilesCancelsInstalls(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
SetTestABMAssets(t, ds, "fleet")
|
||
enableWindowsMDMForReconcileTest(ctx, t, ds)
|
||
|
||
// create some Apple and Windows profiles and declaration
|
||
appleProfs := []*fleet.MDMAppleConfigProfile{
|
||
configProfileForTest(t, "A1", "A1", uuid.NewString()),
|
||
configProfileForTest(t, "A2", "A2", uuid.NewString()),
|
||
configProfileForTest(t, "A3", "A3", uuid.NewString()),
|
||
}
|
||
windowsProfs := []*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "W1", "W1"),
|
||
windowsConfigProfileForTest(t, "W2", "W2"),
|
||
}
|
||
appleDecls := []*fleet.MDMAppleDeclaration{
|
||
declForTest("D1", "D1", "{}"),
|
||
declForTest("D2", "D2", "{}"),
|
||
}
|
||
androidProfs := []*fleet.MDMAndroidConfigProfile{
|
||
androidConfigProfileForTest(t, "G1", nil),
|
||
androidConfigProfileForTest(t, "G2", nil),
|
||
}
|
||
_, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls, androidProfs, nil)
|
||
require.NoError(t, err)
|
||
|
||
// collect the profiles in a lookup table by name
|
||
profNameToProf := make(map[string]*fleet.MDMConfigProfilePayload)
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
for _, prof := range profs {
|
||
profNameToProf[prof.Name] = prof
|
||
}
|
||
|
||
// delete all kinds of profiles without any host impacted
|
||
err = ds.DeleteMDMAppleConfigProfile(ctx, profNameToProf["A1"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
err = ds.DeleteMDMWindowsConfigProfile(ctx, profNameToProf["W1"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
err = ds.DeleteMDMAppleDeclaration(ctx, profNameToProf["D1"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
err = ds.DeleteMDMAndroidConfigProfile(ctx, profNameToProf["G1"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
|
||
// create some macOS, Windows and android hosts
|
||
host1 := test.NewHost(t, ds, "host1", "1", "h1key", "host1uuid", time.Now())
|
||
host2 := test.NewHost(t, ds, "host2", "2", "h2key", "host2uuid", time.Now())
|
||
nanoEnroll(t, ds, host1, false)
|
||
nanoEnroll(t, ds, host2, false)
|
||
|
||
host3 := test.NewHost(t, ds, "host3", "3", "h3key", "host3uuid", time.Now())
|
||
host3.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host3)
|
||
require.NoError(t, err)
|
||
windowsEnroll(t, ds, host3)
|
||
|
||
host4 := test.NewHost(t, ds, "host4", "4", "h4key", "host4uuid", time.Now())
|
||
host4.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host4)
|
||
require.NoError(t, err)
|
||
windowsEnroll(t, ds, host4)
|
||
|
||
host5 := test.NewHost(t, ds, "host5", "5", "h5key", "host5uuid", time.Now())
|
||
host5.Platform = "android"
|
||
err = ds.UpdateHost(ctx, host5)
|
||
require.NoError(t, err)
|
||
|
||
host6 := test.NewHost(t, ds, "host6", "6", "h6key", "host6uuid", time.Now())
|
||
host6.Platform = "android"
|
||
err = ds.UpdateHost(ctx, host6)
|
||
require.NoError(t, err)
|
||
|
||
for _, h := range []*fleet.Host{host1, host2, host3, host4, host5, host6} {
|
||
err = ds.SetOrUpdateMDMData(ctx, h.ID, false, true, "https://fleetdm.com", false, fleet.WellKnownMDMFleet, "", false)
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// set the declaration as pending install on host1, installed on host2
|
||
forceSetAppleHostDeclarationStatus(t, ds, host1.UUID, test.ToMDMAppleDecl(profNameToProf["D2"]), fleet.MDMOperationTypeInstall, "")
|
||
forceSetAppleHostDeclarationStatus(t, ds, host2.UUID, test.ToMDMAppleDecl(profNameToProf["D2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
assertHostProfileOpStatus(t, ds, host1.UUID,
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall})
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall})
|
||
|
||
err = ds.DeleteMDMAppleDeclaration(ctx, profNameToProf["D2"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
|
||
assertHostProfileOpStatus(t, ds, host1.UUID)
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
// set the Windows profile as pending install on host3, installed on host4
|
||
forceSetWindowsHostProfileStatus(t, ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToProf["W2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetWindowsHostProfileStatus(t, ds, host4.UUID, test.ToMDMWindowsConfigProfile(profNameToProf["W2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
assertHostProfileOpStatus(t, ds, host3.UUID,
|
||
hostProfileOpStatus{profNameToProf["W2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall})
|
||
assertHostProfileOpStatus(t, ds, host4.UUID,
|
||
hostProfileOpStatus{profNameToProf["W2"].ProfileUUID, fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall})
|
||
|
||
err = ds.DeleteMDMWindowsConfigProfile(ctx, profNameToProf["W2"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
|
||
// Windows profile removal is async now (#46993): the delete retains the profile content, and the profile-manager cron flips the
|
||
// surviving host rows to remove+pending and enqueues the <Delete>.
|
||
require.NoError(t, service.ReconcileWindowsProfiles(ctx, ds, ds.logger))
|
||
|
||
assertHostProfileOpStatus(t, ds, host3.UUID,
|
||
hostProfileOpStatus{profNameToProf["W2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
assertHostProfileOpStatus(t, ds, host4.UUID,
|
||
hostProfileOpStatus{profNameToProf["W2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
// set the android profile as pending install on host 5 and installed on host 6
|
||
forceSetAndroidHostProfileStatus(t, ds, host5.UUID, test.ToMDMAndroidConfigProfile(profNameToProf["G2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
forceSetAndroidHostProfileStatus(t, ds, host6.UUID, test.ToMDMAndroidConfigProfile(profNameToProf["G2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
assertHostProfileOpStatus(t, ds, host5.UUID,
|
||
hostProfileOpStatus{profNameToProf["G2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall})
|
||
assertHostProfileOpStatus(t, ds, host6.UUID,
|
||
hostProfileOpStatus{profNameToProf["G2"].ProfileUUID, fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall})
|
||
|
||
err = ds.DeleteMDMAndroidConfigProfile(ctx, profNameToProf["G2"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
|
||
// We can't fully delete failed install profiles, as they might have values sent to the device even if some is overriden.
|
||
// So check for pending, remove instead of fully removed
|
||
assertHostProfileOpStatus(t, ds, host5.UUID,
|
||
hostProfileOpStatus{profNameToProf["G2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
assertHostProfileOpStatus(t, ds, host6.UUID,
|
||
hostProfileOpStatus{profNameToProf["G2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
commander, _ := createMDMAppleCommanderAndStorage(t, ds)
|
||
|
||
// set the Apple profile as pending install on host1, installed on host2
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToProf["A2"]), fleet.MDMOperationTypeInstall, "")
|
||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToProf["A2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
|
||
// enqueue the corresponding command for the installed profile
|
||
cmdUUID := uuid.New().String()
|
||
err = commander.InstallProfile(ctx, []string{host2.UUID}, appleProfs[1].Mobileconfig, cmdUUID, "")
|
||
require.NoError(t, err)
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET command_uuid = ? WHERE host_uuid = ? AND profile_uuid = ?`, cmdUUID, host2.UUID, profNameToProf["A2"].ProfileUUID)
|
||
return err
|
||
})
|
||
assertHostProfileOpStatus(t, ds, host1.UUID,
|
||
hostProfileOpStatus{profNameToProf["A2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall})
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profNameToProf["A2"].ProfileUUID, fleet.MDMDeliveryVerifying, fleet.MDMOperationTypeInstall},
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
err = ds.DeleteMDMAppleConfigProfile(ctx, profNameToProf["A2"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
|
||
assertHostProfileOpStatus(t, ds, host1.UUID)
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profNameToProf["A2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove},
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
// nano command is still active because it was already completed (verifying)
|
||
var active bool
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &active, `SELECT active FROM nano_enrollment_queue WHERE id = ? AND command_uuid = ?`, host2.UUID, cmdUUID)
|
||
})
|
||
require.True(t, active)
|
||
|
||
// set the Apple profile as actually pending install (not NULL) on host1
|
||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToProf["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||
// enqueue the corresponding command for the installed profile
|
||
cmdUUID = uuid.New().String()
|
||
err = commander.InstallProfile(ctx, []string{host1.UUID}, appleProfs[2].Mobileconfig, cmdUUID, "")
|
||
require.NoError(t, err)
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET command_uuid = ? WHERE host_uuid = ? AND profile_uuid = ?`, cmdUUID, host1.UUID, profNameToProf["A3"].ProfileUUID)
|
||
return err
|
||
})
|
||
assertHostProfileOpStatus(t, ds, host1.UUID,
|
||
hostProfileOpStatus{profNameToProf["A3"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall})
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profNameToProf["A2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove},
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
err = ds.DeleteMDMAppleConfigProfile(ctx, profNameToProf["A3"].ProfileUUID)
|
||
require.NoError(t, err)
|
||
|
||
assertHostProfileOpStatus(t, ds, host1.UUID,
|
||
hostProfileOpStatus{profNameToProf["A3"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profNameToProf["A2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove},
|
||
hostProfileOpStatus{profNameToProf["D2"].ProfileUUID, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
// nano command is now inactive because it was in a pending state and we want to prevent delivery
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &active, `SELECT active FROM nano_enrollment_queue WHERE id = ? AND command_uuid = ?`, host1.UUID, cmdUUID)
|
||
})
|
||
require.False(t, active)
|
||
|
||
// listing the MDM commands does not return the inactive one
|
||
cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{
|
||
User: test.UserAdmin,
|
||
IncludeObserver: true,
|
||
}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{HostIdentifier: host1.UUID}})
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 0)
|
||
|
||
// Windows hosts have pending removes; cleanup should wipe them.
|
||
err = ds.CleanupAllHostMDMProfilesForPlatform(ctx, "windows")
|
||
require.NoError(t, err)
|
||
assertHostProfileOpStatus(t, ds, host3.UUID)
|
||
assertHostProfileOpStatus(t, ds, host4.UUID)
|
||
|
||
// Windows host_mdm rows must NOT be touched by global disable. Orbit's programmatic-unenrollment notification
|
||
// depends on host_mdm.enrolled = 1 to be able to tell the device to unenroll.
|
||
var winHostMDM struct {
|
||
Total int `db:"total"`
|
||
Enrolled int `db:"enrolled"`
|
||
}
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &winHostMDM,
|
||
`SELECT
|
||
COUNT(*) AS total,
|
||
COALESCE(SUM(CASE WHEN hmdm.enrolled = 1 THEN 1 ELSE 0 END), 0) AS enrolled
|
||
FROM host_mdm hmdm
|
||
JOIN hosts h ON h.id = hmdm.host_id
|
||
WHERE h.uuid IN (?, ?) AND h.platform = 'windows'`,
|
||
host3.UUID, host4.UUID)
|
||
})
|
||
require.Equal(t, 2, winHostMDM.Total, "Windows host_mdm rows must survive global disable")
|
||
require.Equal(t, 2, winHostMDM.Enrolled,
|
||
"Windows host_mdm.enrolled must be preserved so orbit can still send unenroll notifications")
|
||
|
||
// Apple hosts still have pending removes; verify they survived the Windows cleanup, then disable Apple MDM too.
|
||
appleProfsHost1, err := ds.GetHostMDMAppleProfiles(ctx, host1.UUID)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, appleProfsHost1)
|
||
|
||
appleProfsHost2, err := ds.GetHostMDMAppleProfiles(ctx, host2.UUID)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, appleProfsHost2)
|
||
|
||
err = ds.CleanupAllHostMDMProfilesForPlatform(ctx, "darwin")
|
||
require.NoError(t, err)
|
||
assertHostProfileOpStatus(t, ds, host1.UUID)
|
||
assertHostProfileOpStatus(t, ds, host2.UUID)
|
||
|
||
// Apple nano_enrollments must be soft-disabled (rows survive, enabled = 0) so the reconciler does not recreate
|
||
// pending rows when a new APNS cert is uploaded.
|
||
var appleEnrollments struct {
|
||
Total int `db:"total"`
|
||
Disabled int `db:"disabled"`
|
||
}
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &appleEnrollments,
|
||
`SELECT
|
||
COUNT(*) AS total,
|
||
COALESCE(SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END), 0) AS disabled
|
||
FROM nano_enrollments
|
||
WHERE id IN (?, ?)`,
|
||
host1.UUID, host2.UUID)
|
||
})
|
||
require.Positive(t, appleEnrollments.Total, "nano_enrollments rows must survive global disable, not be deleted")
|
||
require.Equal(t, appleEnrollments.Total, appleEnrollments.Disabled, "nano_enrollments rows must be flipped to enabled = 0")
|
||
}
|
||
|
||
// testDeleteTeamCancelsWindowsProfileInstalls verifies that when a team is
|
||
// deleted, <Delete> commands are generated for Windows profiles that were
|
||
// delivered to hosts. This ensures settings are actually removed from devices
|
||
// rather than silently orphaned.
|
||
func testDeleteTeamCancelsWindowsProfileInstalls(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
enableWindowsMDMForReconcileTest(ctx, t, ds)
|
||
|
||
// Create a team with Windows profiles.
|
||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "delete-team-test"})
|
||
require.NoError(t, err)
|
||
|
||
windowsProfs := []*fleet.MDMWindowsConfigProfile{
|
||
windowsConfigProfileForTest(t, "TW1", "TW1"),
|
||
windowsConfigProfileForTest(t, "TW2", "TW2"),
|
||
}
|
||
_, err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, windowsProfs, nil, nil, nil)
|
||
require.NoError(t, err)
|
||
|
||
// Collect profile UUIDs.
|
||
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, profs, 2)
|
||
profUUIDs := make([]string, len(profs))
|
||
for i, p := range profs {
|
||
profUUIDs[i] = p.ProfileUUID
|
||
}
|
||
|
||
// Create Windows hosts enrolled in MDM and assigned to the team.
|
||
host1 := test.NewHost(t, ds, "tw-host1", "tw1", "tw1key", "tw-host1-uuid", time.Now())
|
||
host1.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host1.ID})))
|
||
host1.TeamID = &team.ID
|
||
windowsEnroll(t, ds, host1)
|
||
|
||
host2 := test.NewHost(t, ds, "tw-host2", "tw2", "tw2key", "tw-host2-uuid", time.Now())
|
||
host2.Platform = "windows"
|
||
err = ds.UpdateHost(ctx, host2)
|
||
require.NoError(t, err)
|
||
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host2.ID})))
|
||
host2.TeamID = &team.ID
|
||
windowsEnroll(t, ds, host2)
|
||
|
||
// Simulate profiles delivered to both hosts (install + verified).
|
||
for _, h := range []*fleet.Host{host1, host2} {
|
||
for _, p := range profs {
|
||
forceSetWindowsHostProfileStatus(t, ds, h.UUID,
|
||
test.ToMDMWindowsConfigProfile(p),
|
||
fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
|
||
}
|
||
}
|
||
|
||
// Verify: 4 rows, all install+verified.
|
||
assertHostProfileOpStatus(t, ds, host1.UUID,
|
||
hostProfileOpStatus{profUUIDs[0], fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall},
|
||
hostProfileOpStatus{profUUIDs[1], fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall})
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profUUIDs[0], fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall},
|
||
hostProfileOpStatus{profUUIDs[1], fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall})
|
||
|
||
// Delete the team. Removal is async now (#46993): this retains the profiles' content and moves the hosts to No team; the
|
||
// profile-manager cron then flips their rows and enqueues the <Delete> commands.
|
||
err = ds.DeleteTeam(ctx, team.ID)
|
||
require.NoError(t, err)
|
||
|
||
// Config profiles should be gone.
|
||
teamProfs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
|
||
require.NoError(t, err)
|
||
require.Len(t, teamProfs, 0)
|
||
|
||
require.NoError(t, service.ReconcileWindowsProfiles(ctx, ds, ds.logger))
|
||
|
||
// Host-profile rows should be remove+pending (not remove+NULL, not deleted).
|
||
assertHostProfileOpStatus(t, ds, host1.UUID,
|
||
hostProfileOpStatus{profUUIDs[0], fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove},
|
||
hostProfileOpStatus{profUUIDs[1], fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
assertHostProfileOpStatus(t, ds, host2.UUID,
|
||
hostProfileOpStatus{profUUIDs[0], fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove},
|
||
hostProfileOpStatus{profUUIDs[1], fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove})
|
||
|
||
// Verify <Delete> commands were enqueued.
|
||
var deleteCmdCount int
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &deleteCmdCount, `
|
||
SELECT COUNT(*) FROM windows_mdm_command_queue wmcq
|
||
JOIN windows_mdm_commands wmc ON wmc.command_uuid = wmcq.command_uuid
|
||
WHERE wmc.raw_command LIKE '%<Delete>%'`)
|
||
})
|
||
// 2 profiles × 2 hosts = 4 queue entries (but command rows are shared per profile)
|
||
require.Equal(t, 4, deleteCmdCount, "expected 4 delete command queue entries (2 profiles × 2 hosts)")
|
||
}
|
||
|
||
func androidConfigProfileForTest(t *testing.T, name string, content map[string]any, labels ...*fleet.Label) *fleet.MDMAndroidConfigProfile {
|
||
if content == nil {
|
||
content = make(map[string]any)
|
||
}
|
||
content["name"] = name
|
||
rawJSON, err := json.Marshal(content)
|
||
require.NoError(t, err)
|
||
|
||
prof := &fleet.MDMAndroidConfigProfile{
|
||
Name: name,
|
||
RawJSON: rawJSON,
|
||
}
|
||
|
||
for _, lbl := range labels {
|
||
switch {
|
||
case strings.HasPrefix(lbl.Name, "exclude-"):
|
||
prof.LabelsExcludeAny = append(prof.LabelsExcludeAny, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
|
||
case strings.HasPrefix(lbl.Name, "include-any-"):
|
||
prof.LabelsIncludeAny = append(prof.LabelsIncludeAny, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
|
||
default:
|
||
prof.LabelsIncludeAll = append(prof.LabelsIncludeAll, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
|
||
}
|
||
}
|
||
|
||
return prof
|
||
}
|
||
|
||
func forceSetWindowsHostProfileStatus(t *testing.T, ds *Datastore, hostUUID string, profile *fleet.MDMWindowsConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
|
||
ctx := t.Context()
|
||
|
||
// empty status string means set to NULL
|
||
var actualStatus *fleet.MDMDeliveryStatus
|
||
if status != "" {
|
||
actualStatus = &status
|
||
}
|
||
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_windows_profiles
|
||
(host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid)
|
||
VALUES
|
||
(?, ?, ?, ?, ?, UNHEX(MD5(?)), ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
status = VALUES(status),
|
||
operation_type = VALUES(operation_type)
|
||
`,
|
||
hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.SyncML, profile.ProfileUUID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
func forceSetAppleHostDeclarationStatus(t *testing.T, ds *Datastore, hostUUID string, profile *fleet.MDMAppleDeclaration, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
|
||
ctx := t.Context()
|
||
|
||
// empty status string means set to NULL
|
||
var actualStatus *fleet.MDMDeliveryStatus
|
||
if status != "" {
|
||
actualStatus = &status
|
||
}
|
||
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
token := uuid.New() // Generate binary UUID
|
||
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_declarations
|
||
(declaration_identifier, host_uuid, status, operation_type, token, declaration_name, declaration_uuid)
|
||
VALUES
|
||
(?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
status = VALUES(status),
|
||
operation_type = VALUES(operation_type)
|
||
`,
|
||
profile.Identifier, hostUUID, actualStatus, operation, token[:], profile.Name, profile.DeclarationUUID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
func forceSetAndroidHostProfileStatus(t *testing.T, ds *Datastore, hostUUID string, profile *fleet.MDMAndroidConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
|
||
ctx := t.Context()
|
||
|
||
// empty status string means set to NULL
|
||
var actualStatus *fleet.MDMDeliveryStatus
|
||
if status != "" {
|
||
actualStatus = &status
|
||
}
|
||
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_android_profiles
|
||
(host_uuid, status, operation_type, profile_name, profile_uuid)
|
||
VALUES
|
||
(?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
status = VALUES(status),
|
||
operation_type = VALUES(operation_type)
|
||
`,
|
||
hostUUID, actualStatus, operation, profile.Name, profile.ProfileUUID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
func testEnqueueCommandWithName(t *testing.T, ds *Datastore) {
|
||
ctx := context.Background()
|
||
|
||
SetTestABMAssets(t, ds, "fleet")
|
||
|
||
// Create and enroll a macOS host
|
||
macH, err := ds.NewHost(ctx, &fleet.Host{
|
||
Hostname: "test-host-cmd-name",
|
||
OsqueryHostID: new("osquery-macos-cmd-name"),
|
||
NodeKey: new("node-key-macos-cmd-name"),
|
||
UUID: uuid.NewString(),
|
||
Platform: "darwin",
|
||
HardwareSerial: "CMDNAME123",
|
||
})
|
||
require.NoError(t, err)
|
||
nanoEnroll(t, ds, macH, false)
|
||
|
||
commander, mdmStorage := createMDMAppleCommanderAndStorage(t, ds)
|
||
|
||
// Test 1: InstallProfile with a name
|
||
cmdUUID1 := uuid.New().String()
|
||
mc := mobileconfig.Mobileconfig(fmt.Appendf(nil, `<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>PayloadContent</key>
|
||
<array/>
|
||
<key>PayloadDisplayName</key>
|
||
<string>Test Profile</string>
|
||
<key>PayloadIdentifier</key>
|
||
<string>com.test.profile</string>
|
||
<key>PayloadType</key>
|
||
<string>Configuration</string>
|
||
<key>PayloadUUID</key>
|
||
<string>%s</string>
|
||
<key>PayloadVersion</key>
|
||
<integer>1</integer>
|
||
</dict>
|
||
</plist>`, uuid.New().String()))
|
||
err = commander.InstallProfile(ctx, []string{macH.UUID}, mc, cmdUUID1, "Test Profile Name")
|
||
require.NoError(t, err)
|
||
|
||
// Verify name in DB
|
||
var storedName sql.NullString
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &storedName, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID1)
|
||
})
|
||
require.True(t, storedName.Valid)
|
||
require.Equal(t, "Test Profile Name", storedName.String)
|
||
|
||
// Also verify via ListMDMCommands
|
||
cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}})
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 1)
|
||
require.NotNil(t, cmds[0].Name)
|
||
require.Equal(t, "Test Profile Name", *cmds[0].Name)
|
||
|
||
// Test 2: EnqueueCommand without a name
|
||
cmdUUID2 := uuid.New().String()
|
||
rawCmd := createRawAppleCmd("ProfileList", cmdUUID2)
|
||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, rawCmd)
|
||
require.NoError(t, err)
|
||
|
||
// Verify name is NULL in DB
|
||
var storedName2 sql.NullString
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &storedName2, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID2)
|
||
})
|
||
require.False(t, storedName2.Valid)
|
||
|
||
// Verify name is null in the API
|
||
// Verify ListMDMCommands also exposes nil Name for unnamed commands
|
||
cmds, _, _, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}})
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 2)
|
||
|
||
gotByUUID := make(map[string]*fleet.MDMCommand, len(cmds))
|
||
for _, c := range cmds {
|
||
gotByUUID[c.CommandUUID] = c
|
||
}
|
||
require.Equal(t, "Test Profile Name", *gotByUUID[cmdUUID1].Name)
|
||
require.Nil(t, gotByUUID[cmdUUID2].Name)
|
||
|
||
// Test 3: Name exactly 255 characters — should NOT be truncated
|
||
cmdUUID3 := uuid.New().String()
|
||
name255 := strings.Repeat("a", 255)
|
||
rawXML3 := createRawAppleCmd("InstallProfile", cmdUUID3)
|
||
decodedCmd3, err := mdm.DecodeCommand([]byte(rawXML3))
|
||
require.NoError(t, err)
|
||
_, err = mdmStorage.EnqueueCommand(ctx, []string{macH.UUID}, &mdm.CommandWithSubtype{
|
||
Command: *decodedCmd3,
|
||
Subtype: mdm.CommandSubtypeNone,
|
||
Name: name255,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
var storedName3 sql.NullString
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &storedName3, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID3)
|
||
})
|
||
require.True(t, storedName3.Valid)
|
||
require.Equal(t, name255, storedName3.String)
|
||
require.Len(t, []rune(storedName3.String), 255)
|
||
|
||
// Test 4: Name longer than 255 ASCII characters — should be truncated to 255
|
||
cmdUUID4 := uuid.New().String()
|
||
name300 := strings.Repeat("b", 300)
|
||
rawXML4 := createRawAppleCmd("InstallProfile", cmdUUID4)
|
||
decodedCmd4, err := mdm.DecodeCommand([]byte(rawXML4))
|
||
require.NoError(t, err)
|
||
_, err = mdmStorage.EnqueueCommand(ctx, []string{macH.UUID}, &mdm.CommandWithSubtype{
|
||
Command: *decodedCmd4,
|
||
Subtype: mdm.CommandSubtypeNone,
|
||
Name: name300,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
var storedName4 sql.NullString
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &storedName4, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID4)
|
||
})
|
||
require.True(t, storedName4.Valid)
|
||
require.Equal(t, strings.Repeat("b", 255), storedName4.String)
|
||
require.Len(t, []rune(storedName4.String), 255)
|
||
|
||
// Test 5: Name with multi-byte utf8mb4 characters exceeding 255 — should truncate to 255 runes (not bytes)
|
||
cmdUUID5 := uuid.New().String()
|
||
// Each emoji is a single rune but 4 bytes in utf8mb4. Build a 300-rune string.
|
||
nameMultibyte := strings.Repeat("🍎", 300)
|
||
require.Len(t, []rune(nameMultibyte), 300)
|
||
rawXML5 := createRawAppleCmd("InstallProfile", cmdUUID5)
|
||
decodedCmd5, err := mdm.DecodeCommand([]byte(rawXML5))
|
||
require.NoError(t, err)
|
||
_, err = mdmStorage.EnqueueCommand(ctx, []string{macH.UUID}, &mdm.CommandWithSubtype{
|
||
Command: *decodedCmd5,
|
||
Subtype: mdm.CommandSubtypeNone,
|
||
Name: nameMultibyte,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
var storedName5 sql.NullString
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &storedName5, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID5)
|
||
})
|
||
require.True(t, storedName5.Valid)
|
||
require.Equal(t, strings.Repeat("🍎", 255), storedName5.String)
|
||
require.Len(t, []rune(storedName5.String), 255)
|
||
}
|
||
|
||
func testCleanUpMDMManagedCertificates(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
DetailUpdatedAt: time.Now(),
|
||
LabelUpdatedAt: time.Now(),
|
||
PolicyUpdatedAt: time.Now(),
|
||
SeenTime: time.Now(),
|
||
OsqueryHostID: ptr.String("host0-osquery-id"),
|
||
NodeKey: ptr.String("host0-node-key"),
|
||
UUID: "host0-test-mdm-profiles",
|
||
Hostname: "hostname0",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
t.Run("non matching host profile record", func(t *testing.T) {
|
||
badProfileUUID := uuid.NewString()
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
INSERT INTO host_mdm_managed_certificates (host_uuid, profile_uuid) VALUES (?, ?)
|
||
`, host.UUID, badProfileUUID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
var uid string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM host_mdm_managed_certificates WHERE profile_uuid = ?`,
|
||
badProfileUUID)
|
||
})
|
||
require.Equal(t, badProfileUUID, uid)
|
||
|
||
// Cleanup should delete the above orphaned record
|
||
err = ds.CleanUpMDMManagedCertificates(ctx)
|
||
require.NoError(t, err)
|
||
err = ExecAdhocSQLWithError(ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM host_mdm_managed_certificates WHERE profile_uuid = ?`,
|
||
badProfileUUID)
|
||
})
|
||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||
})
|
||
|
||
t.Run("valid windows record stays", func(t *testing.T) {
|
||
t.Cleanup(func() {
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
DELETE FROM host_mdm_managed_certificates;
|
||
DELETE FROM host_mdm_windows_profiles;
|
||
`)
|
||
return err
|
||
})
|
||
})
|
||
windowsProfileUUID := uuid.NewString()
|
||
err := ds.BulkUpsertMDMWindowsHostProfiles(ctx, []*fleet.MDMWindowsBulkUpsertHostProfilePayload{{
|
||
ProfileUUID: windowsProfileUUID,
|
||
HostUUID: host.UUID,
|
||
Checksum: []byte("gibberish"),
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
}})
|
||
require.NoError(t, err)
|
||
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
INSERT INTO host_mdm_managed_certificates (host_uuid, profile_uuid) VALUES (?, ?)
|
||
`, host.UUID, windowsProfileUUID)
|
||
return err
|
||
})
|
||
|
||
// Validate record stays after cleanup
|
||
err = ds.CleanUpMDMManagedCertificates(ctx)
|
||
require.NoError(t, err)
|
||
var uid string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM host_mdm_managed_certificates WHERE profile_uuid = ?`,
|
||
windowsProfileUUID)
|
||
})
|
||
require.Equal(t, windowsProfileUUID, uid)
|
||
})
|
||
|
||
t.Run("valid apple record stays", func(t *testing.T) {
|
||
t.Cleanup(func() {
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
DELETE FROM host_mdm_managed_certificates;
|
||
DELETE FROM host_mdm_apple_profiles;
|
||
`)
|
||
return err
|
||
})
|
||
})
|
||
|
||
appleProfileUUID := uuid.NewString()
|
||
err := ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||
ProfileUUID: appleProfileUUID,
|
||
HostUUID: host.UUID,
|
||
Checksum: []byte("gibberish"),
|
||
Scope: fleet.PayloadScopeSystem,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
}})
|
||
require.NoError(t, err)
|
||
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
INSERT INTO host_mdm_managed_certificates (host_uuid, profile_uuid) VALUES (?, ?)
|
||
`, host.UUID, appleProfileUUID)
|
||
return err
|
||
})
|
||
|
||
// Validate record stays after cleanup
|
||
err = ds.CleanUpMDMManagedCertificates(ctx)
|
||
require.NoError(t, err)
|
||
var uid string
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM host_mdm_managed_certificates WHERE profile_uuid = ?`,
|
||
appleProfileUUID)
|
||
})
|
||
require.Equal(t, appleProfileUUID, uid)
|
||
})
|
||
}
|
||
|
||
func testProfileHasACMEPayloadForCommand(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
DetailUpdatedAt: time.Now(),
|
||
LabelUpdatedAt: time.Now(),
|
||
PolicyUpdatedAt: time.Now(),
|
||
SeenTime: time.Now(),
|
||
OsqueryHostID: ptr.String("acme-probe-osq"),
|
||
NodeKey: ptr.String("acme-probe-nk"),
|
||
UUID: "acme-probe-host-uuid",
|
||
Hostname: "acme-probe-host",
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
mkProfile := func(t *testing.T, name string, mobileconfig []byte) string {
|
||
t.Helper()
|
||
teamID := uint(0)
|
||
profileUUID := uuid.NewString()
|
||
stmt := `
|
||
INSERT INTO mdm_apple_configuration_profiles
|
||
(profile_uuid, team_id, identifier, name, mobileconfig, checksum, uploaded_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, NOW())`
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, stmt,
|
||
profileUUID, teamID, name, name, mobileconfig, []byte("0123456789abcdef"))
|
||
return err
|
||
})
|
||
return profileUUID
|
||
}
|
||
|
||
mkHostProfileLink := func(t *testing.T, hostUUID, profileUUID, commandUUID string) {
|
||
t.Helper()
|
||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||
ProfileUUID: profileUUID,
|
||
HostUUID: hostUUID,
|
||
Checksum: []byte("0123456789abcdef"),
|
||
Scope: fleet.PayloadScopeSystem,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
CommandUUID: commandUUID,
|
||
}}))
|
||
}
|
||
|
||
acmeXML := []byte(`<?xml version="1.0"?><plist><dict><key>PayloadContent</key><array><dict><key>PayloadType</key><string>com.apple.security.acme</string></dict></array></dict></plist>`)
|
||
scepXML := []byte(`<?xml version="1.0"?><plist><dict><key>PayloadContent</key><array><dict><key>PayloadType</key><string>com.apple.security.scep</string></dict></array></dict></plist>`)
|
||
|
||
t.Run("darwin host with ACME profile, no pending refetch", func(t *testing.T) {
|
||
profUUID := mkProfile(t, "acme-darwin", acmeXML)
|
||
cmdUUID := uuid.NewString()
|
||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||
|
||
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.Equal(t, host.ID, got.HostID)
|
||
require.Equal(t, "darwin", got.Platform)
|
||
require.Equal(t, profUUID, got.ProfileUUID)
|
||
require.True(t, got.HasACMEPayload)
|
||
})
|
||
|
||
t.Run("darwin host with non-ACME profile reports has_acme_payload=false", func(t *testing.T) {
|
||
profUUID := mkProfile(t, "scep-darwin", scepXML)
|
||
cmdUUID := uuid.NewString()
|
||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||
|
||
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.Equal(t, "darwin", got.Platform)
|
||
require.False(t, got.HasACMEPayload)
|
||
})
|
||
|
||
t.Run("flag persists after the config profile is deleted", func(t *testing.T) {
|
||
// The RemoveProfile flow: the config profile is gone but the persisted
|
||
// flag must still report ACME.
|
||
profUUID := mkProfile(t, "acme-deleted", acmeXML)
|
||
cmdUUID := uuid.NewString()
|
||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?`, profUUID)
|
||
return err
|
||
})
|
||
|
||
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.Equal(t, "darwin", got.Platform)
|
||
require.True(t, got.HasACMEPayload, "flag must survive config profile deletion")
|
||
})
|
||
|
||
t.Run("remove-op upsert preserves the install-time flag", func(t *testing.T) {
|
||
profUUID := mkProfile(t, "acme-removeop", acmeXML)
|
||
cmdUUID := uuid.NewString()
|
||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||
|
||
// Remove-op upsert after the config profile is gone: the subquery
|
||
// yields 0, but the ON DUPLICATE KEY UPDATE guard must preserve the flag.
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?`, profUUID)
|
||
return err
|
||
})
|
||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||
ProfileUUID: profUUID,
|
||
HostUUID: host.UUID,
|
||
Checksum: []byte("0123456789abcdef"),
|
||
Scope: fleet.PayloadScopeSystem,
|
||
OperationType: fleet.MDMOperationTypeRemove,
|
||
CommandUUID: cmdUUID,
|
||
}}))
|
||
|
||
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.True(t, got.HasACMEPayload, "remove-op upsert must not reset the flag")
|
||
})
|
||
|
||
t.Run("unknown command returns not found", func(t *testing.T) {
|
||
_, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, "no-such-command")
|
||
require.Error(t, err)
|
||
require.True(t, fleet.IsNotFound(err))
|
||
})
|
||
|
||
t.Run("unknown host returns not found", func(t *testing.T) {
|
||
profUUID := mkProfile(t, "acme-unknown-host", acmeXML)
|
||
cmdUUID := uuid.NewString()
|
||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||
|
||
_, err := ds.ProfileHasACMEPayloadForCommand(ctx, "no-such-host", cmdUUID)
|
||
require.Error(t, err)
|
||
require.True(t, fleet.IsNotFound(err))
|
||
})
|
||
}
|
||
|
||
func testOktaCACleanupTargetForInstallCommand(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
newHost := func(t *testing.T, suffix, platform string) *fleet.Host {
|
||
t.Helper()
|
||
h, err := ds.NewHost(ctx, &fleet.Host{
|
||
DetailUpdatedAt: time.Now(),
|
||
LabelUpdatedAt: time.Now(),
|
||
PolicyUpdatedAt: time.Now(),
|
||
SeenTime: time.Now(),
|
||
OsqueryHostID: ptr.String("okta-cleanup-osq-" + suffix),
|
||
NodeKey: ptr.String("okta-cleanup-nk-" + suffix),
|
||
UUID: "okta-cleanup-host-" + suffix,
|
||
Hostname: "okta-cleanup-" + suffix,
|
||
Platform: platform,
|
||
})
|
||
require.NoError(t, err)
|
||
return h
|
||
}
|
||
|
||
insertHostProfile := func(t *testing.T, hostUUID, identifier, commandUUID string) {
|
||
t.Helper()
|
||
// host_mdm_apple_profiles has no FK to mdm_apple_configuration_profiles,
|
||
// so we can insert the host-profile row directly with a fresh
|
||
// profile_uuid per sub-test and avoid the (team_id, identifier)
|
||
// unique-key conflict that comes from reusing the Okta CA identifier.
|
||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||
ProfileUUID: uuid.NewString(),
|
||
ProfileIdentifier: identifier,
|
||
HostUUID: hostUUID,
|
||
Checksum: []byte("0123456789abcdef"),
|
||
Scope: fleet.PayloadScopeUser,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
CommandUUID: commandUUID,
|
||
}}))
|
||
}
|
||
|
||
t.Run("okta CA profile + per-user enrollment present: returns target", func(t *testing.T) {
|
||
host := newHost(t, "happy", "darwin")
|
||
nanoEnroll(t, ds, host, true) // creates Device + User enrollment with user_short_name = "alice"
|
||
cmdUUID := uuid.NewString()
|
||
insertHostProfile(t, host.UUID, fleet.ConditionalAccessOktaProfileIdentifier, cmdUUID)
|
||
|
||
got, ok, err := ds.OktaCACleanupTargetForInstallCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.True(t, ok)
|
||
require.Equal(t, host.ID, got.HostID)
|
||
require.Equal(t, "alice", got.UserShortName)
|
||
})
|
||
|
||
t.Run("non-Okta profile identifier: ok=false", func(t *testing.T) {
|
||
host := newHost(t, "wrong-id", "darwin")
|
||
nanoEnroll(t, ds, host, true)
|
||
cmdUUID := uuid.NewString()
|
||
insertHostProfile(t, host.UUID, "com.example.unrelated", cmdUUID)
|
||
|
||
_, ok, err := ds.OktaCACleanupTargetForInstallCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.False(t, ok)
|
||
})
|
||
|
||
t.Run("okta profile + only device-channel enrollment: ok=false", func(t *testing.T) {
|
||
host := newHost(t, "no-user-chan", "darwin")
|
||
nanoEnroll(t, ds, host, false) // Device-only enrollment, no nano_users row
|
||
cmdUUID := uuid.NewString()
|
||
insertHostProfile(t, host.UUID, fleet.ConditionalAccessOktaProfileIdentifier, cmdUUID)
|
||
|
||
_, ok, err := ds.OktaCACleanupTargetForInstallCommand(ctx, host.UUID, cmdUUID)
|
||
require.NoError(t, err)
|
||
require.False(t, ok)
|
||
})
|
||
|
||
t.Run("unknown command: ok=false, no error", func(t *testing.T) {
|
||
host := newHost(t, "no-cmd", "darwin")
|
||
nanoEnroll(t, ds, host, true)
|
||
|
||
_, ok, err := ds.OktaCACleanupTargetForInstallCommand(ctx, host.UUID, "no-such-cmd")
|
||
require.NoError(t, err)
|
||
require.False(t, ok)
|
||
})
|
||
}
|
||
|
||
func testRenewMDMManagedCertificatesNullType(t *testing.T, ds *Datastore) {
|
||
ctx := t.Context()
|
||
|
||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||
DetailUpdatedAt: time.Now(),
|
||
LabelUpdatedAt: time.Now(),
|
||
PolicyUpdatedAt: time.Now(),
|
||
SeenTime: time.Now(),
|
||
OsqueryHostID: ptr.String("renew-null-osq"),
|
||
NodeKey: ptr.String("renew-null-nk"),
|
||
UUID: "renew-null-host-uuid",
|
||
Hostname: "renew-null-host",
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// Helper: create an Apple config profile + a host_mdm_apple_profiles row
|
||
// in 'verified' state (eligible for renewal cron resend) and an associated
|
||
// host_mdm_managed_certificates row with the given type and an expiring
|
||
// not_valid_after. Returns the profile UUID.
|
||
mkExpiringRow := func(t *testing.T, name string, certType *string, caName string) string {
|
||
t.Helper()
|
||
profileUUID := uuid.NewString()
|
||
// Insert mdm_apple_configuration_profiles row.
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
INSERT INTO mdm_apple_configuration_profiles
|
||
(profile_uuid, team_id, identifier, name, mobileconfig, checksum, uploaded_at)
|
||
VALUES (?, 0, ?, ?, ?, ?, NOW())`,
|
||
profileUUID, name, name, []byte("dummy"), []byte("0123456789abcdef"))
|
||
return err
|
||
})
|
||
// Insert host_mdm_apple_profiles row in verified state, eligible for renewal.
|
||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||
ProfileUUID: profileUUID,
|
||
ProfileIdentifier: name,
|
||
ProfileName: name,
|
||
HostUUID: host.UUID,
|
||
Status: &fleet.MDMDeliveryVerified,
|
||
OperationType: fleet.MDMOperationTypeInstall,
|
||
CommandUUID: "cmd-" + profileUUID,
|
||
Checksum: []byte("0123456789abcdef"),
|
||
Scope: fleet.PayloadScopeSystem,
|
||
}}))
|
||
// Insert host_mdm_managed_certificates row with cert that expires soon
|
||
// (within the renewal cron's 30-day threshold, validity_period > 30).
|
||
notValidBefore := time.Now().AddDate(-1, 0, 0) // 1 year ago
|
||
notValidAfter := time.Now().AddDate(0, 0, 5) // 5 days from now
|
||
serial := "0000000000000000000000000000000000000001"
|
||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||
_, err := q.ExecContext(ctx, `
|
||
INSERT INTO host_mdm_managed_certificates
|
||
(host_uuid, profile_uuid, ca_name, type,
|
||
not_valid_before, not_valid_after, serial)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
host.UUID, profileUUID, caName, certType, notValidBefore, notValidAfter, serial)
|
||
return err
|
||
})
|
||
return profileUUID
|
||
}
|
||
|
||
ndesStr := "ndes"
|
||
|
||
// Two rows expiring on the same schedule: one with NULL type
|
||
// (non-proxied flow) and one with type='ndes' (proxied flow).
|
||
// Both buckets must be picked up by the renewal cron.
|
||
nullProfile := mkExpiringRow(t, "null-prof", nil, "non-proxied-ca")
|
||
ndesProfile := mkExpiringRow(t, "ndes-prof", &ndesStr, "ndes-ca")
|
||
|
||
// Sanity: both start as 'verified'.
|
||
for _, profUUID := range []string{nullProfile, ndesProfile} {
|
||
var status *string
|
||
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &status, `
|
||
SELECT status FROM host_mdm_apple_profiles
|
||
WHERE host_uuid = ? AND profile_uuid = ?`,
|
||
host.UUID, profUUID))
|
||
require.NotNil(t, status)
|
||
require.Equal(t, fleet.MDMDeliveryVerified, fleet.MDMDeliveryStatus(*status))
|
||
}
|
||
|
||
require.NoError(t, ds.RenewMDMManagedCertificates(ctx))
|
||
|
||
// Both should now have status=NULL (queued for resend).
|
||
for _, profUUID := range []string{nullProfile, ndesProfile} {
|
||
var status *string
|
||
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &status, `
|
||
SELECT status FROM host_mdm_apple_profiles
|
||
WHERE host_uuid = ? AND profile_uuid = ?`,
|
||
host.UUID, profUUID))
|
||
require.Nil(t, status, "profile %s should be queued for resend", profUUID)
|
||
}
|
||
|
||
// Verify the read paths handle NULL `type` cleanly. The struct fields
|
||
// `MDMManagedCertificate.Type` and `HostMDMCertificateProfile.Type` are
|
||
// `CAConfigAssetType` (a string alias), not pointers. sqlx scans a NULL
|
||
// column into a string-aliased field as the empty string — no error, no
|
||
// special-case handling needed. This is the convention used throughout the
|
||
// non-proxied flow: NULL in the column == zero value in Go.
|
||
listed, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID)
|
||
require.NoError(t, err, "ListHostMDMManagedCertificates must round-trip rows with NULL type")
|
||
var sawNullRow, sawNDESRow bool
|
||
for _, row := range listed {
|
||
switch row.ProfileUUID {
|
||
case nullProfile:
|
||
sawNullRow = true
|
||
require.Equal(t, fleet.CAConfigAssetType(""), row.Type,
|
||
"NULL type column should scan to empty CAConfigAssetType")
|
||
require.Equal(t, "non-proxied-ca", row.CAName)
|
||
case ndesProfile:
|
||
sawNDESRow = true
|
||
require.Equal(t, fleet.CAConfigNDES, row.Type,
|
||
"non-NULL type column should round-trip unchanged")
|
||
}
|
||
}
|
||
require.True(t, sawNullRow, "ListHostMDMManagedCertificates must return the NULL-type row")
|
||
require.True(t, sawNDESRow, "ListHostMDMManagedCertificates must return the existing ndes row")
|
||
|
||
// Same expectation via GetAppleHostMDMCertificateProfile, which returns
|
||
// HostMDMCertificateProfile (different struct, same nullable column).
|
||
nullProfileDetail, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, nullProfile, "non-proxied-ca")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, nullProfileDetail)
|
||
require.Equal(t, fleet.CAConfigAssetType(""), nullProfileDetail.Type,
|
||
"HostMDMCertificateProfile.Type must scan a NULL column as empty string")
|
||
|
||
ndesProfileDetail, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, ndesProfile, "ndes-ca")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, ndesProfileDetail)
|
||
require.Equal(t, fleet.CAConfigNDES, ndesProfileDetail.Type)
|
||
}
|