fleet/server/service/apple_mdm_ddm_test.go
Martin Angers 2a8803884b
DDMV: Support Fleet variables in DDM (#43222)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43047 

# Checklist for submitter

- [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] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## 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
See
https://github.com/fleetdm/fleet/issues/42960#issuecomment-4244206563
and subsequent comments.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Apple DDM declarations support a vetted subset of Fleet variables with
per-host substitution; premium license required. Declaration tokens and
resend behavior now reflect variable changes; unresolved host
substitutions mark that host’s declaration as failed.

* **Bug Fixes**
* Clearer errors for unsupported or license-restricted Fleet variables
and more consistent DDM resend/update semantics when variables change.

* **Tests**
* Added extensive unit and integration tests covering Fleet variable
validation, substitution, token changes, resends, and failure states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-20 09:14:52 -04:00

410 lines
18 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func TestDeclarativeManagement_DeclarationItems(t *testing.T) {
ctx := t.Context()
ds := mysql.CreateMySQLDS(t)
ddmService := MDMAppleDDMService{
ds: ds,
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
}
// Helper function to create a host
createHost := func(t *testing.T, hostUUID, hardwareSerial string) {
_, err := ds.NewHost(context.Background(), &fleet.Host{
UUID: hostUUID,
Hostname: "test-host-" + hostUUID,
HardwareSerial: hardwareSerial,
PrimaryIP: "192.168.1.1",
PrimaryMac: "00:00:00:00:00:00",
OsqueryHostID: ptr.String(hostUUID),
NodeKey: ptr.String(hostUUID),
DetailUpdatedAt: time.Now(),
})
require.NoError(t, err)
}
// Helper function to create a declaration
createDeclaration := func(t *testing.T, uuid, name, identifier string) *fleet.MDMAppleDeclaration {
declaration := &fleet.MDMAppleDeclaration{
DeclarationUUID: uuid,
Name: name,
Identifier: identifier,
TeamID: nil,
RawJSON: []byte(fmt.Sprintf(`{"Type":"com.apple.test.declaration","Identifier":"%s"}`, identifier)),
}
declaration, err := ds.NewMDMAppleDeclaration(context.Background(), declaration, nil)
require.NoError(t, err)
return declaration
}
// Helper function to set up device and enrollment records
setupDeviceAndEnrollment := func(t *testing.T, hostUUID, hardwareSerial string) {
// Insert the device record first (required for foreign key constraints)
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO nano_devices (id, serial_number, authenticate) VALUES (?, ?, ?)`,
hostUUID, hardwareSerial, "test")
return err
})
// Insert a record into nano_enrollments table (required for foreign key constraints)
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex, enabled, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
hostUUID, hostUUID, "Device", "topic", "push_magic", "token_hex", 1, time.Now())
return err
})
}
// Helper function to insert a host declaration
insertHostDeclaration := func(t *testing.T, hostUUID, declarationUUID, status, operationType, identifier string) string {
var token string
var statusPtr *string
if status != "" {
statusPtr = ptr.String(status)
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
// First, get the right token of the declaration
err := sqlx.GetContext(ctx, q, &token,
"SELECT HEX(token) as token FROM mdm_apple_declarations WHERE declaration_uuid = ?", declarationUUID)
require.NoError(t, err)
_, err = q.ExecContext(ctx, `
INSERT INTO host_mdm_apple_declarations
(host_uuid, declaration_uuid, status, operation_type, token, declaration_identifier)
VALUES (?, ?, ?, ?, UNHEX(?), ?)`,
hostUUID, declarationUUID, statusPtr, operationType, token, identifier)
return err
})
return token
}
// Helper function to call DeclarativeManagement and verify response
callDeclarativeManagementAndVerify := func(t *testing.T, hostUUID string,
expectedConfigurations, expectedActivations int,
) fleet.MDMAppleDDMDeclarationItemsResponse {
req := mdm.Request{
Context: ctx,
EnrollID: &mdm.EnrollID{
ID: hostUUID,
},
}
dm := mdm.DeclarativeManagement{}
dm.UDID = hostUUID
dm.Endpoint = "declaration-items"
response, err := ddmService.DeclarativeManagement(&req, &dm)
require.NoError(t, err)
require.NotNil(t, response)
// Parse the response
var declarationItemsResponse fleet.MDMAppleDDMDeclarationItemsResponse
err = json.Unmarshal(response, &declarationItemsResponse)
require.NoError(t, err)
// Verify the declarations in the response
require.Len(t, declarationItemsResponse.Declarations.Configurations, expectedConfigurations)
require.Len(t, declarationItemsResponse.Declarations.Activations, expectedActivations)
return declarationItemsResponse
}
// Helper function to check if a declaration has status "pending"
checkDeclarationStatus := func(t *testing.T, hostUUID, declarationUUID, expectedStatus string) {
var status string
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
db := q.(*sqlx.DB)
return db.QueryRowContext(ctx, `
SELECT status FROM host_mdm_apple_declarations
WHERE host_uuid = ? AND declaration_uuid = ?`,
hostUUID, declarationUUID).Scan(&status)
})
require.Equal(t, expectedStatus, status)
}
// Helper function to set the uploaded_at timestamp for a host declaration
setDeclarationUploadedAt := func(t *testing.T, declarationUUID string, timestamp time.Time) {
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE mdm_apple_declarations
SET uploaded_at = ?
WHERE declaration_uuid = ?`,
timestamp, declarationUUID)
return err
})
}
t.Run("SingleDeclaration", func(t *testing.T) {
hostUUID := "test-host-uuid-1"
hardwareSerial := "ABC123-1"
// Create a test host
createHost(t, hostUUID, hardwareSerial)
// Create a test declaration
declaration := createDeclaration(t, "test-declaration-uuid-1", "Test Declaration 1", "com.example.test.declaration.1")
// Set up device and enrollment records
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
// Insert a host declaration
token := insertHostDeclaration(t, hostUUID, declaration.DeclarationUUID, "pending", "install", declaration.Identifier)
// Get the expected declarations token from the DB.
expectedToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
require.NoError(t, err)
// Call DeclarativeManagement and verify response
response := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1)
// Verify the token in the response matches the expected token
require.Equal(t, expectedToken.DeclarationsToken, response.DeclarationsToken)
// Verify the declarations in the response
require.Equal(t, declaration.Identifier, response.Declarations.Configurations[0].Identifier)
require.Equal(t, token, response.Declarations.Configurations[0].ServerToken)
// Verify the activations in the response
require.Equal(t, declaration.Identifier+".activation", response.Declarations.Activations[0].Identifier)
require.Equal(t, token, response.Declarations.Activations[0].ServerToken)
})
t.Run("NoDeclarations", func(t *testing.T) {
hostUUID := "test-host-uuid-2"
hardwareSerial := "ABC123-2"
// Create a test host
createHost(t, hostUUID, hardwareSerial)
// Set up device and enrollment records
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
// Call DeclarativeManagement and verify response
response := callDeclarativeManagementAndVerify(t, hostUUID, 0, 0)
// Get the expected declarations token from the DB.
expectedToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
require.NoError(t, err)
// Verify the token in the response matches the expected token
require.Equal(t, expectedToken.DeclarationsToken, response.DeclarationsToken)
})
t.Run("MultipleDeclarations", func(t *testing.T) {
hostUUID := "test-host-uuid-3"
hardwareSerial := "ABC123-3"
// Create a test host
createHost(t, hostUUID, hardwareSerial)
// Create test declarations
declaration1 := createDeclaration(t, "test-declaration-uuid-3-1", "Test Declaration 3-1", "com.example.test.declaration.3.1")
declaration2 := createDeclaration(t, "test-declaration-uuid-3-2", "Test Declaration 3-2", "com.example.test.declaration.3.2")
declaration3 := createDeclaration(t, "test-declaration-uuid-3-3", "Test Declaration 3-3", "com.example.test.declaration.3.3")
// Set up device and enrollment records
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
// Insert host declarations
insertHostDeclaration(t, hostUUID, declaration1.DeclarationUUID, "pending", "install", declaration1.Identifier)
insertHostDeclaration(t, hostUUID, declaration2.DeclarationUUID, "pending", "install", declaration2.Identifier)
insertHostDeclaration(t, hostUUID, declaration3.DeclarationUUID, "pending", "remove", declaration3.Identifier)
// Get the expected declarations token from the DB.
expectedToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
require.NoError(t, err)
// Call DeclarativeManagement and verify response
response := callDeclarativeManagementAndVerify(t, hostUUID, 2, 2)
// Verify the token in the response matches the expected token
require.Equal(t, expectedToken.DeclarationsToken, response.DeclarationsToken)
// Verify the declarations in the response (only install operations)
identifiers := []string{
response.Declarations.Configurations[0].Identifier,
response.Declarations.Configurations[1].Identifier,
}
require.Contains(t, identifiers, declaration1.Identifier)
require.Contains(t, identifiers, declaration2.Identifier)
require.NotContains(t, identifiers, declaration3.Identifier)
// Verify the activations in the response
activationIdentifiers := []string{
response.Declarations.Activations[0].Identifier,
response.Declarations.Activations[1].Identifier,
}
require.Contains(t, activationIdentifiers, declaration1.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration2.Identifier+".activation")
require.NotContains(t, activationIdentifiers, declaration3.Identifier+".activation")
})
t.Run("RemoveDeclarationsWithNullStatus", func(t *testing.T) {
hostUUID := "test-host-uuid-4"
hardwareSerial := "ABC123-4"
// Create a test host
createHost(t, hostUUID, hardwareSerial)
// Create test declarations
declaration1 := createDeclaration(t, "test-declaration-uuid-4-1", "Test Declaration 4-1", "com.example.test.declaration.4.1")
declaration2 := createDeclaration(t, "test-declaration-uuid-4-2", "Test Declaration 4-2", "com.example.test.declaration.4.2")
declaration3 := createDeclaration(t, "test-declaration-uuid-4-3", "Test Declaration 4-3", "com.example.test.declaration.4.3")
// Set up device and enrollment records
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
// Insert host declarations
token1 := insertHostDeclaration(t, hostUUID, declaration1.DeclarationUUID, "pending", "install", declaration1.Identifier)
// Use empty string for NULL status
insertHostDeclaration(t, hostUUID, declaration2.DeclarationUUID, "", "remove", declaration2.Identifier)
insertHostDeclaration(t, hostUUID, declaration3.DeclarationUUID, "", "remove", declaration3.Identifier)
// Get the expected declarations token from the DB.
expectedToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
require.NoError(t, err)
// Call DeclarativeManagement and verify response
response := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1)
// Verify the token in the response matches the expected token
require.Equal(t, expectedToken.DeclarationsToken, response.DeclarationsToken)
// Verify the declarations in the response (only install operations)
require.Equal(t, declaration1.Identifier, response.Declarations.Configurations[0].Identifier)
require.Equal(t, token1, response.Declarations.Configurations[0].ServerToken)
// Verify the activations in the response
require.Equal(t, declaration1.Identifier+".activation", response.Declarations.Activations[0].Identifier)
require.Equal(t, token1, response.Declarations.Activations[0].ServerToken)
// Check that the remove declarations with NULL status were updated to "pending"
checkDeclarationStatus(t, hostUUID, declaration2.DeclarationUUID, "pending")
checkDeclarationStatus(t, hostUUID, declaration3.DeclarationUUID, "pending")
})
t.Run("DeclarationsWithSameUploadedAt", func(t *testing.T) {
hostUUID := "test-host-uuid-5"
hardwareSerial := "ABC123-5"
// Create a test host
createHost(t, hostUUID, hardwareSerial)
// Create test declarations - 5 with same timestamp, 3 with different timestamps
declaration1 := createDeclaration(t, "test-declaration-uuid-5-1", "Test Declaration 5-1", "com.example.test.declaration.5.1")
declaration2 := createDeclaration(t, "test-declaration-uuid-5-2", "Test Declaration 5-2", "com.example.test.declaration.5.2")
declaration3 := createDeclaration(t, "test-declaration-uuid-5-3", "Test Declaration 5-3", "com.example.test.declaration.5.3")
declaration4 := createDeclaration(t, "test-declaration-uuid-5-4", "Test Declaration 5-4", "com.example.test.declaration.5.4")
declaration5 := createDeclaration(t, "test-declaration-uuid-5-5", "Test Declaration 5-5", "com.example.test.declaration.5.5")
declaration6 := createDeclaration(t, "test-declaration-uuid-5-6", "Test Declaration 5-6", "com.example.test.declaration.5.6")
declaration7 := createDeclaration(t, "test-declaration-uuid-5-7", "Test Declaration 5-7", "com.example.test.declaration.5.7")
declaration8 := createDeclaration(t, "test-declaration-uuid-5-8", "Test Declaration 5-8", "com.example.test.declaration.5.8")
// Set up device and enrollment records
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
// Insert host declarations
token1 := insertHostDeclaration(t, hostUUID, declaration1.DeclarationUUID, "pending", "install", declaration1.Identifier)
token2 := insertHostDeclaration(t, hostUUID, declaration2.DeclarationUUID, "pending", "install", declaration2.Identifier)
token3 := insertHostDeclaration(t, hostUUID, declaration3.DeclarationUUID, "pending", "install", declaration3.Identifier)
token4 := insertHostDeclaration(t, hostUUID, declaration4.DeclarationUUID, "pending", "install", declaration4.Identifier)
token5 := insertHostDeclaration(t, hostUUID, declaration5.DeclarationUUID, "pending", "install", declaration5.Identifier)
token6 := insertHostDeclaration(t, hostUUID, declaration6.DeclarationUUID, "pending", "install", declaration6.Identifier)
token7 := insertHostDeclaration(t, hostUUID, declaration7.DeclarationUUID, "pending", "install", declaration7.Identifier)
token8 := insertHostDeclaration(t, hostUUID, declaration8.DeclarationUUID, "pending", "install", declaration8.Identifier)
// Set the same uploaded_at timestamp for first 5 declarations
sameTimestamp := time.Now()
setDeclarationUploadedAt(t, declaration1.DeclarationUUID, sameTimestamp)
setDeclarationUploadedAt(t, declaration2.DeclarationUUID, sameTimestamp)
setDeclarationUploadedAt(t, declaration3.DeclarationUUID, sameTimestamp)
setDeclarationUploadedAt(t, declaration4.DeclarationUUID, sameTimestamp)
setDeclarationUploadedAt(t, declaration5.DeclarationUUID, sameTimestamp)
// Set different uploaded_at timestamps for the other 3 declarations
setDeclarationUploadedAt(t, declaration6.DeclarationUUID, sameTimestamp.Add(1*time.Hour))
setDeclarationUploadedAt(t, declaration7.DeclarationUUID, sameTimestamp.Add(2*time.Hour))
setDeclarationUploadedAt(t, declaration8.DeclarationUUID, sameTimestamp.Add(3*time.Hour))
// Get the expected declarations token from the DB.
expectedToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
require.NoError(t, err)
// Call DeclarativeManagement and verify response
response := callDeclarativeManagementAndVerify(t, hostUUID, 8, 8)
// Verify the token in the response matches the expected token
require.Equal(t, expectedToken.DeclarationsToken, response.DeclarationsToken)
// Verify the declarations in the response
configIdentifiers := make([]string, 8)
configTokens := make([]string, 8)
for i, config := range response.Declarations.Configurations {
configIdentifiers[i] = config.Identifier
configTokens[i] = config.ServerToken
}
// Check that all declarations are included
require.Contains(t, configIdentifiers, declaration1.Identifier)
require.Contains(t, configIdentifiers, declaration2.Identifier)
require.Contains(t, configIdentifiers, declaration3.Identifier)
require.Contains(t, configIdentifiers, declaration4.Identifier)
require.Contains(t, configIdentifiers, declaration5.Identifier)
require.Contains(t, configIdentifiers, declaration6.Identifier)
require.Contains(t, configIdentifiers, declaration7.Identifier)
require.Contains(t, configIdentifiers, declaration8.Identifier)
// Check that all tokens are included
require.Contains(t, configTokens, token1)
require.Contains(t, configTokens, token2)
require.Contains(t, configTokens, token3)
require.Contains(t, configTokens, token4)
require.Contains(t, configTokens, token5)
require.Contains(t, configTokens, token6)
require.Contains(t, configTokens, token7)
require.Contains(t, configTokens, token8)
// Verify the activations in the response
activationIdentifiers := make([]string, 8)
activationTokens := make([]string, 8)
for i, activation := range response.Declarations.Activations {
activationIdentifiers[i] = activation.Identifier
activationTokens[i] = activation.ServerToken
}
// Check that all activation identifiers are included
require.Contains(t, activationIdentifiers, declaration1.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration2.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration3.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration4.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration5.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration6.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration7.Identifier+".activation")
require.Contains(t, activationIdentifiers, declaration8.Identifier+".activation")
// Check that all activation tokens are included
require.Contains(t, activationTokens, token1)
require.Contains(t, activationTokens, token2)
require.Contains(t, activationTokens, token3)
require.Contains(t, activationTokens, token4)
require.Contains(t, activationTokens, token5)
require.Contains(t, activationTokens, token6)
require.Contains(t, activationTokens, token7)
require.Contains(t, activationTokens, token8)
})
}