**Related issue:** Resolves #49971 Serves the custom activations stored by #49970 to devices. Stacked on #50280. - The manifest advertises a custom activation's own identifier; the fetch serves its stored JSON with the predicate intact. Secrets and host variables are expanded at delivery, like configurations. - `activation_updated_at` folds into the token, so editing only a predicate re-syncs the host even though the declaration's content didn't change. - Management declarations are served under the manifest's `Management` section, have their own endpoint, and report status. - Predicate outcomes map per the Figma dev note: `Info.Predicate` → verified with an explanation, `Error.ActivationFailed` → failed. ### Three things to look at **Generated activations are renamed to `<declaration uuid>.activation`.** Removes the collision between an admin-chosen identifier and one Fleet generates, and collapses the fetch into a single lookup rather than match-then-trim. Note this differs from the CoS wording of "byte-for-byte identical to today". Existing hosts will see their activations renamed once. The sync token doesn't move for the rename alone — activation identifiers aren't part of it, confirmed in `MDMAppleDDMDeclarationsToken` — so it happens lazily on the next real DDM change rather than all at once. **`activation_updated_at` is not the `max()` the issue describes.** The issue asks for `max(activation uploaded_at, latest variable change)`. Variable changes stay on `variables_updated_at`; this is `GREATEST(uploaded_at, secrets_updated_at)`. Same outcome, since `EffectiveDDMToken` hashes all the timestamps together, but each signal stays independently comparable, which is what keeps the reconciler idempotent. **The token is computed in three places, not two.** SQL in `MDMAppleDDMDeclarationsToken`, Go in `EffectiveDDMToken`, and a third independent copy building the manifest-level token in `handleDeclarationItems`. All three now carry the new term in the same order. `ActivationUpdatedAtFoldsIntoToken` asserts the SQL and Go agree with it populated — they only agreed for the nil case before. # Checklist for submitter - [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] QA'd all new/changed functionality manually Tested on an ADE-enrolled Mac (macOS 26.5.1) against a local server: the custom activation is advertised and fetched by its own identifier, an activation-only edit re-syncs the device even though the declaration is unchanged, a true predicate enforces the policy on the host, and a false one comes back verified with the explanation. That turned up two bugs, both fixed here and both invisible to the test suite: - `MDMAppleStoreDDMStatusReport` computed the matching token with `activation_updated_at` but never selected the column, so it was always nil. Every status report was silently dropped and profiles sat in verifying forever. - Apple splits a predicate outcome across two arrays — the activation reports `Info.Predicate`, the configuration it gates reports `Error.ActivationFailed`. Reading only the configuration made a host the predicate excluded look failed. Now correlated via the activation identifier in the failure details. The test payloads for the second one are the real reports the Mac sent, not hand-written. Note for anyone testing in the UI: the OS settings tooltip for a verified profile is hardcoded to "The host applied the setting. Fleet verified." and doesn't read `detail` yet, so a predicate-excluded profile reads wrong there. Backend stores the right message; rendering it is #49973. New cases in `apple_mdm_ddm_test.go`: the token agreement above; a custom activation advertised and served with its predicate, with the generated name no longer resolving for it; a declaration without one still getting the synthesized activation; management routing plus the endpoint type guard; the three predicate status outcomes; and a host outside a declaration's scope getting an empty manifest and an error fetching the activation by name. 13 existing assertions moved from `<identifier>.activation` to `<uuid>.activation`. The `GeneratedActivationStillServedWhenNoCustomOne` case caught a real bug on its first run: the `LEFT JOIN` returns NULL for declarations without a custom activation, which `json.RawMessage` can't scan, so every non-custom activation fetch would have 500'd. That's the no-regression path. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Apple DDM management declarations and custom activation delivery. * Added host-scoped activation resolution, activation identifiers, and activation update timestamps. * Added support for activation variables, secrets, assets, and separate management and configuration endpoints. * **Bug Fixes** * Declarations are now redelivered when activation predicates change or are removed. * Improved status reporting for predicate exclusions, activation failures, and management declarations. * Improved synchronization token updates when variables, assets, or activations change. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1289 lines
57 KiB
Go
1289 lines
57 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
fleetmdm "github.com/fleetdm/fleet/v4/server/mdm"
|
|
"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"
|
|
)
|
|
|
|
// Custom host vital values are arbitrary admin/external strings, so one may
|
|
// contain a literal $FLEET_VAR_<name>. variables.Replace is a blind global
|
|
// string replace, so vitals must be expanded after the Fleet-var pass — else a
|
|
// $FLEET_VAR_ token embedded in a vital value would be rewritten by that pass.
|
|
func TestReplaceDeclarationFleetVariablesExpandsVitalsLast(t *testing.T) {
|
|
ctx := t.Context()
|
|
ds := mysqltest.CreateMySQLDS(t)
|
|
svc := MDMAppleDDMService{
|
|
ds: ds,
|
|
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
|
|
}
|
|
|
|
host, err := ds.NewHost(ctx, &fleet.Host{
|
|
UUID: "vital-order-uuid",
|
|
Hostname: "vital-order-host",
|
|
HardwareSerial: "SERIAL123",
|
|
OsqueryHostID: new("vital-order"),
|
|
NodeKey: new("vital-order"),
|
|
DetailUpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
vital, err := ds.CreateCustomHostVital(ctx, "asset_tag")
|
|
require.NoError(t, err)
|
|
// The vital's value deliberately embeds a literal $FLEET_VAR_ token.
|
|
require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vital.ID, "tag-$FLEET_VAR_HOST_HARDWARE_SERIAL"))
|
|
|
|
contents := fmt.Sprintf(`{"vital":"$FLEET_HOST_VITAL_%d","serial":"$FLEET_VAR_HOST_HARDWARE_SERIAL"}`, vital.ID)
|
|
out, err := svc.replaceDeclarationFleetVariables(ctx, contents, host.UUID)
|
|
require.NoError(t, err)
|
|
|
|
// The genuine $FLEET_VAR_HOST_HARDWARE_SERIAL reference expands to the serial,
|
|
// but the identical token inside the vital's value survives intact because
|
|
// vitals are expanded last (variables.Replace never sees it).
|
|
require.JSONEq(t, `{"vital":"tag-$FLEET_VAR_HOST_HARDWARE_SERIAL","serial":"SERIAL123"}`, out)
|
|
}
|
|
|
|
func TestDeclarativeManagement_DeclarationItems(t *testing.T) {
|
|
ctx := t.Context()
|
|
ds := mysqltest.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)
|
|
mysqltest.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)
|
|
mysqltest.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)
|
|
}
|
|
mysqltest.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
|
|
mysqltest.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) {
|
|
mysqltest.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, fleet.PayloadScopeSystem)
|
|
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.DeclarationUUID+".activation", response.Declarations.Activations[0].Identifier)
|
|
require.Equal(t, token, response.Declarations.Activations[0].ServerToken)
|
|
})
|
|
|
|
t.Run("ActivationUpdatedAtFoldsIntoToken", func(t *testing.T) {
|
|
hostUUID := "test-host-uuid-act"
|
|
hardwareSerial := "ABC123-ACT"
|
|
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
declaration := createDeclaration(t, "test-declaration-uuid-act", "Test Declaration Act", "com.example.test.declaration.act")
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
insertHostDeclaration(t, hostUUID, declaration.DeclarationUUID, "pending", "install", declaration.Identifier)
|
|
|
|
tokenBefore, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
respBefore := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1)
|
|
require.Equal(t, tokenBefore.DeclarationsToken, respBefore.DeclarationsToken)
|
|
|
|
// Stamping activation_updated_at must move the token, and the SQL and Go
|
|
// computations must still agree. They are written independently, so a
|
|
// mismatch would re-sync every host on every check-in.
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx,
|
|
`UPDATE host_mdm_apple_declarations SET activation_updated_at = NOW(6) WHERE host_uuid = ?`, hostUUID)
|
|
return err
|
|
})
|
|
|
|
tokenAfter, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
respAfter := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1)
|
|
|
|
require.Equal(t, tokenAfter.DeclarationsToken, respAfter.DeclarationsToken, "SQL and Go tokens must agree")
|
|
require.NotEqual(t, tokenBefore.DeclarationsToken, tokenAfter.DeclarationsToken, "activation change must move the token")
|
|
})
|
|
|
|
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, fleet.PayloadScopeSystem)
|
|
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, fleet.PayloadScopeSystem)
|
|
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.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration2.DeclarationUUID+".activation")
|
|
require.NotContains(t, activationIdentifiers, declaration3.DeclarationUUID+".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, fleet.PayloadScopeSystem)
|
|
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.DeclarationUUID+".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, fleet.PayloadScopeSystem)
|
|
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.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration2.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration3.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration4.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration5.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration6.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration7.DeclarationUUID+".activation")
|
|
require.Contains(t, activationIdentifiers, declaration8.DeclarationUUID+".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)
|
|
})
|
|
|
|
t.Run("UserChannelScopeIsolation", func(t *testing.T) {
|
|
hostUUID := "test-host-uuid-user-scope"
|
|
hardwareSerial := "ABC123-USER-SCOPE"
|
|
userEnrollmentID := hostUUID + ":user-1"
|
|
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
// Add a user-channel enrollment for the same device.
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `INSERT INTO nano_users (id, device_id, user_short_name, user_long_name) VALUES (?, ?, ?, ?)`,
|
|
"user-1", hostUUID, "u", "user")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = q.ExecContext(ctx, `INSERT INTO nano_enrollments (id, device_id, user_id, type, topic, push_magic, token_hex, enabled, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
userEnrollmentID, hostUUID, "user-1", "User", "topic", "push_magic", "token_hex_user", 1, time.Now())
|
|
return err
|
|
})
|
|
|
|
// A device-scoped and a user-scoped declaration.
|
|
deviceDecl := createDeclaration(t, "user-scope-device-decl", "DeviceDecl", "com.example.userscope.device")
|
|
userDeclRaw := &fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "user-scope-user-decl",
|
|
Name: "UserDecl",
|
|
Identifier: "com.example.userscope.user",
|
|
RawJSON: []byte(`{"Type":"com.apple.test.declaration","Identifier":"com.example.userscope.user"}`),
|
|
Scope: fleet.PayloadScopeUser,
|
|
}
|
|
userDecl, err := ds.NewMDMAppleDeclaration(ctx, userDeclRaw, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Apple supports management declarations on the user channel too, and
|
|
// nothing in Fleet scopes by declaration type, so it must ride along.
|
|
userMgmtDecl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "user-scope-user-mgmt",
|
|
Name: "UserMgmtDecl",
|
|
Identifier: "com.example.userscope.mgmt",
|
|
RawJSON: []byte(`{"Type":"com.apple.management.organization-info","Identifier":"com.example.userscope.mgmt","Payload":{"Name":"Fleet"}}`),
|
|
Scope: fleet.PayloadScopeUser,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
insertScopedHostDeclaration := func(declUUID, identifier string, scope fleet.PayloadScope) {
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
var token string
|
|
if err := sqlx.GetContext(ctx, q, &token, "SELECT HEX(token) FROM mdm_apple_declarations WHERE declaration_uuid = ?", declUUID); err != nil {
|
|
return err
|
|
}
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO host_mdm_apple_declarations
|
|
(host_uuid, declaration_uuid, status, operation_type, token, declaration_identifier, scope)
|
|
VALUES (?, ?, 'pending', 'install', UNHEX(?), ?, ?)`,
|
|
hostUUID, declUUID, token, identifier, scope)
|
|
return err
|
|
})
|
|
}
|
|
insertScopedHostDeclaration(deviceDecl.DeclarationUUID, deviceDecl.Identifier, fleet.PayloadScopeSystem)
|
|
insertScopedHostDeclaration(userDecl.DeclarationUUID, userDecl.Identifier, fleet.PayloadScopeUser)
|
|
insertScopedHostDeclaration(userMgmtDecl.DeclarationUUID, userMgmtDecl.Identifier, fleet.PayloadScopeUser)
|
|
|
|
callChannel := func(enrollID *mdm.EnrollID) fleet.MDMAppleDDMDeclarationItemsResponse {
|
|
req := mdm.Request{Context: ctx, EnrollID: enrollID}
|
|
dm := mdm.DeclarativeManagement{}
|
|
dm.UDID = hostUUID
|
|
dm.Endpoint = "declaration-items"
|
|
response, err := ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, response)
|
|
var parsed fleet.MDMAppleDDMDeclarationItemsResponse
|
|
require.NoError(t, json.Unmarshal(response, &parsed))
|
|
return parsed
|
|
}
|
|
|
|
// Device channel: only the device declaration, and the token matches the
|
|
// SQL-computed System token (parity).
|
|
deviceResp := callChannel(&mdm.EnrollID{ID: hostUUID})
|
|
require.Len(t, deviceResp.Declarations.Configurations, 1)
|
|
require.Equal(t, deviceDecl.Identifier, deviceResp.Declarations.Configurations[0].Identifier)
|
|
require.Empty(t, deviceResp.Declarations.Management, "the user-scoped management declaration must not leak to the device channel")
|
|
sysToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
require.Equal(t, sysToken.DeclarationsToken, deviceResp.DeclarationsToken)
|
|
|
|
// User channel (EnrollID with ParentID set): only the user declaration, and
|
|
// the token matches the SQL-computed User token (parity).
|
|
userResp := callChannel(&mdm.EnrollID{ID: userEnrollmentID, ParentID: hostUUID})
|
|
require.Len(t, userResp.Declarations.Configurations, 1)
|
|
require.Equal(t, userDecl.Identifier, userResp.Declarations.Configurations[0].Identifier)
|
|
require.Len(t, userResp.Declarations.Management, 1)
|
|
require.Equal(t, userMgmtDecl.Identifier, userResp.Declarations.Management[0].Identifier)
|
|
userToken, err := ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID, fleet.PayloadScopeUser)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userToken.DeclarationsToken, userResp.DeclarationsToken)
|
|
|
|
// The two channels produce different tokens.
|
|
require.NotEqual(t, deviceResp.DeclarationsToken, userResp.DeclarationsToken)
|
|
})
|
|
|
|
t.Run("DeliveryStripsPayloadScope", func(t *testing.T) {
|
|
hostUUID := "test-host-uuid-strip"
|
|
hardwareSerial := "ABC123-STRIP"
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
|
|
// The stored declaration retains the Fleet-only top-level PayloadScope key
|
|
// (it's only stripped at delivery).
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "strip-decl",
|
|
Name: "StripDecl",
|
|
Identifier: "com.example.strip",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.strip","PayloadScope":"System","Payload":{"Enabled":true}}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(decl.RawJSON), "PayloadScope", "stored raw_json keeps PayloadScope")
|
|
|
|
insertHostDeclaration(t, hostUUID, decl.DeclarationUUID, "pending", "install", decl.Identifier)
|
|
|
|
// Fetch the full configuration declaration served to the device.
|
|
req := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: hostUUID}}
|
|
dm := mdm.DeclarativeManagement{}
|
|
dm.UDID = hostUUID
|
|
dm.Endpoint = "declaration/configuration/" + decl.Identifier
|
|
response, err := ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
|
|
var served map[string]any
|
|
require.NoError(t, json.Unmarshal(response, &served))
|
|
require.NotContains(t, served, "PayloadScope", "PayloadScope must be stripped from the declaration served to the device")
|
|
require.Equal(t, "com.example.strip", served["Identifier"])
|
|
require.Contains(t, served, "ServerToken")
|
|
})
|
|
|
|
t.Run("PredicateStatusMapping", func(t *testing.T) {
|
|
// Payloads below are the real reports a macOS 26 host sent to a Fleet
|
|
// server, not hand-written. Apple splits a predicate outcome across two
|
|
// arrays: the activation carries Info.Predicate, while the configuration
|
|
// it gates reports Error.ActivationFailed. Reading only the
|
|
// configuration makes a host the predicate simply excluded look failed.
|
|
predicateFalse := func(configIdent, activationIdent, token string) fleet.MDMAppleDDMStatusReport {
|
|
var r fleet.MDMAppleDDMStatusReport
|
|
r.StatusItems.Management.Declarations.Activations = []fleet.MDMAppleDDMStatusDeclaration{{
|
|
Identifier: activationIdent,
|
|
Active: false,
|
|
Valid: fleet.MDMAppleDeclarationValid,
|
|
ServerToken: token,
|
|
Reasons: []fleet.MDMAppleDDMStatusErrorReason{{
|
|
Code: fleet.MDMAppleDDMReasonPredicate,
|
|
Description: "Activations (" + activationIdent + ") predicate (FALSEPREDICATE) evaluated to false.",
|
|
Details: map[string]any{
|
|
"Identifier": activationIdent,
|
|
"ServerToken": token,
|
|
"Predicate": "FALSEPREDICATE",
|
|
},
|
|
}},
|
|
}}
|
|
r.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{{
|
|
Identifier: configIdent,
|
|
Active: false,
|
|
Valid: fleet.MDMAppleDeclarationUnknown,
|
|
ServerToken: token,
|
|
Reasons: []fleet.MDMAppleDDMStatusErrorReason{{
|
|
Code: fleet.MDMAppleDDMReasonActivationFailed,
|
|
Description: "Activation " + activationIdent + " has errors.",
|
|
Details: map[string]any{
|
|
"Identifier": activationIdent,
|
|
"ServerToken": token,
|
|
},
|
|
}},
|
|
}}
|
|
return r
|
|
}
|
|
|
|
// Same shape, but the activation reports no Info.Predicate -- the
|
|
// activation genuinely failed rather than being scoped out.
|
|
activationBroken := func(configIdent, activationIdent, token string) fleet.MDMAppleDDMStatusReport {
|
|
r := predicateFalse(configIdent, activationIdent, token)
|
|
r.StatusItems.Management.Declarations.Activations[0].Reasons = nil
|
|
return r
|
|
}
|
|
|
|
applied := func(configIdent, token string) fleet.MDMAppleDDMStatusReport {
|
|
var r fleet.MDMAppleDDMStatusReport
|
|
r.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{{
|
|
Identifier: configIdent,
|
|
Active: true,
|
|
Valid: fleet.MDMAppleDeclarationValid,
|
|
ServerToken: token,
|
|
}}
|
|
return r
|
|
}
|
|
|
|
// Apple: "A management declaration has an active state which is always
|
|
// false and not part of the activation process", so these report
|
|
// Active:false even when fully applied. Grading them like configurations
|
|
// left every one of them stuck on verifying.
|
|
management := func(ident, token string, valid fleet.MDMAppleDeclarationValidity) fleet.MDMAppleDDMStatusReport {
|
|
var r fleet.MDMAppleDDMStatusReport
|
|
r.StatusItems.Management.Declarations.Management = []fleet.MDMAppleDDMStatusDeclaration{{
|
|
Identifier: ident,
|
|
Active: false,
|
|
Valid: valid,
|
|
ServerToken: token,
|
|
}}
|
|
return r
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
report func(configIdent, activationIdent, token string) fleet.MDMAppleDDMStatusReport
|
|
declRawJSON string
|
|
wantStatus fleet.MDMDeliveryStatus
|
|
wantDetail string
|
|
}{
|
|
{
|
|
name: "valid management declaration is verified despite being inactive",
|
|
report: func(ident, _ string, token string) fleet.MDMAppleDDMStatusReport {
|
|
return management(ident, token, fleet.MDMAppleDeclarationValid)
|
|
},
|
|
declRawJSON: `{"Type":"com.apple.management.organization-info","Identifier":"%s","Payload":{"Name":"Fleet"}}`,
|
|
wantStatus: fleet.MDMDeliveryVerified,
|
|
},
|
|
{
|
|
name: "invalid management declaration is failed",
|
|
report: func(ident, _ string, token string) fleet.MDMAppleDDMStatusReport {
|
|
return management(ident, token, fleet.MDMAppleDeclarationInvalid)
|
|
},
|
|
declRawJSON: `{"Type":"com.apple.management.organization-info","Identifier":"%s","Payload":{"Name":"Fleet"}}`,
|
|
wantStatus: fleet.MDMDeliveryFailed,
|
|
},
|
|
{
|
|
name: "unchecked management declaration stays verifying",
|
|
report: func(ident, _ string, token string) fleet.MDMAppleDDMStatusReport {
|
|
return management(ident, token, fleet.MDMAppleDeclarationUnknown)
|
|
},
|
|
declRawJSON: `{"Type":"com.apple.management.organization-info","Identifier":"%s","Payload":{"Name":"Fleet"}}`,
|
|
wantStatus: fleet.MDMDeliveryVerifying,
|
|
},
|
|
{
|
|
// Unknown means "not checked yet", but reasons mean something already
|
|
// went wrong -- without this it would wait for a verdict forever.
|
|
name: "unknown management declaration reporting errors is failed",
|
|
report: func(ident, _ string, token string) fleet.MDMAppleDDMStatusReport {
|
|
r := management(ident, token, fleet.MDMAppleDeclarationUnknown)
|
|
r.StatusItems.Management.Declarations.Management[0].Reasons = []fleet.MDMAppleDDMStatusErrorReason{{
|
|
Code: "Error.InvalidPayload",
|
|
Description: "ManagementPayload (" + ident + ") has an invalid payload.",
|
|
}}
|
|
return r
|
|
},
|
|
declRawJSON: `{"Type":"com.apple.management.organization-info","Identifier":"%s","Payload":{"Name":"Fleet"}}`,
|
|
wantStatus: fleet.MDMDeliveryFailed,
|
|
},
|
|
{
|
|
name: "predicate excluded the host is verified, not failed",
|
|
report: predicateFalse,
|
|
wantStatus: fleet.MDMDeliveryVerified,
|
|
wantDetail: "Fleet verified, but predicate (FALSEPREDICATE) evaluated to false and settings were not applied to this host.",
|
|
},
|
|
{
|
|
name: "activation failure without a predicate reason is failed",
|
|
report: activationBroken,
|
|
wantStatus: fleet.MDMDeliveryFailed,
|
|
},
|
|
{
|
|
name: "applied configuration is verified",
|
|
report: func(configIdent, _ string, token string) fleet.MDMAppleDDMStatusReport {
|
|
return applied(configIdent, token)
|
|
},
|
|
wantStatus: fleet.MDMDeliveryVerified,
|
|
},
|
|
}
|
|
|
|
for i, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
suffix := fmt.Sprintf("%d", i)
|
|
hostUUID := "test-host-uuid-pred-" + suffix
|
|
hardwareSerial := "PRED-" + suffix
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
|
|
configIdent := "com.example.pred." + suffix
|
|
activationIdent := configIdent + ".custom"
|
|
rawJSON := `{"Type":"com.apple.configuration.test","Identifier":"` + configIdent + `","Payload":{"Enabled":true}}`
|
|
if c.declRawJSON != "" {
|
|
rawJSON = fmt.Sprintf(c.declRawJSON, configIdent)
|
|
}
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "PredDecl-" + suffix,
|
|
Identifier: configIdent,
|
|
RawJSON: []byte(rawJSON),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
token := insertHostDeclaration(t, hostUUID, decl.DeclarationUUID, "pending", "install", decl.Identifier)
|
|
|
|
raw, err := json.Marshal(c.report(configIdent, activationIdent, token))
|
|
require.NoError(t, err)
|
|
|
|
req := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: hostUUID}}
|
|
dm := mdm.DeclarativeManagement{Data: raw}
|
|
dm.UDID = hostUUID
|
|
dm.Endpoint = "status"
|
|
_, err = ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
|
|
var got struct {
|
|
Status *string `db:"status"`
|
|
Detail string `db:"detail"`
|
|
}
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &got,
|
|
`SELECT status, COALESCE(detail, '') AS detail FROM host_mdm_apple_declarations WHERE host_uuid = ?`, hostUUID)
|
|
})
|
|
require.NotNil(t, got.Status)
|
|
require.Equal(t, string(c.wantStatus), *got.Status)
|
|
if c.wantDetail != "" {
|
|
require.Equal(t, c.wantDetail, got.Detail)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("CustomActivationIsScopedToHostsThatHaveTheDeclaration", func(t *testing.T) {
|
|
inScopeUUID, inScopeSerial := "test-host-uuid-scoped-in", "SCOPE-IN"
|
|
outOfScopeUUID, outOfScopeSerial := "test-host-uuid-scoped-out", "SCOPE-OUT"
|
|
|
|
createHost(t, inScopeUUID, inScopeSerial)
|
|
setupDeviceAndEnrollment(t, inScopeUUID, inScopeSerial)
|
|
createHost(t, outOfScopeUUID, outOfScopeSerial)
|
|
setupDeviceAndEnrollment(t, outOfScopeUUID, outOfScopeSerial)
|
|
|
|
teamID := uint(42)
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "ScopedDecl",
|
|
Identifier: "com.example.scoped",
|
|
TeamID: &teamID,
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.scoped","Payload":{"Enabled":true}}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
Activation: &fleet.MDMAppleCustomActivation{
|
|
Identifier: "com.example.scoped.act",
|
|
RawJSON: []byte(`{"Type":"com.apple.activation.simple","Identifier":"com.example.scoped.act","Payload":{"StandardConfigurations":["com.example.scoped"]}}`),
|
|
ConfigurationIdentifier: "com.example.scoped",
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Only the in-scope host gets a host_mdm_apple_declarations row, which is
|
|
// what team and label scoping ultimately produce.
|
|
insertHostDeclaration(t, inScopeUUID, decl.DeclarationUUID, "pending", "install", decl.Identifier)
|
|
|
|
manifest := callDeclarativeManagementAndVerify(t, inScopeUUID, 1, 1)
|
|
require.Equal(t, "com.example.scoped.act", manifest.Declarations.Activations[0].Identifier)
|
|
|
|
req := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: inScopeUUID}}
|
|
dm := mdm.DeclarativeManagement{}
|
|
dm.UDID = inScopeUUID
|
|
dm.Endpoint = "declaration/activation/com.example.scoped.act"
|
|
_, err = ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
|
|
// The out-of-scope host sees nothing, and cannot fetch the activation by
|
|
// name even though it exists in the database.
|
|
outManifest := callDeclarativeManagementAndVerify(t, outOfScopeUUID, 0, 0)
|
|
require.Empty(t, outManifest.Declarations.Activations)
|
|
|
|
outReq := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: outOfScopeUUID}}
|
|
outDM := mdm.DeclarativeManagement{}
|
|
outDM.UDID = outOfScopeUUID
|
|
outDM.Endpoint = "declaration/activation/com.example.scoped.act"
|
|
_, err = ddmService.DeclarativeManagement(&outReq, &outDM)
|
|
require.Error(t, err, "a host outside the declaration's scope must not resolve its activation")
|
|
})
|
|
|
|
t.Run("CustomActivationCarriesItsOwnToken", func(t *testing.T) {
|
|
hostUUID, hardwareSerial := "test-host-uuid-acttoken", "ACT-TOKEN"
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
|
|
teamID := uint(43)
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "ActTokenDecl",
|
|
Identifier: "com.example.acttoken",
|
|
TeamID: &teamID,
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.acttoken","Payload":{"Enabled":true}}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
Activation: &fleet.MDMAppleCustomActivation{
|
|
Identifier: "com.example.acttoken.act",
|
|
RawJSON: []byte(`{"Type":"com.apple.activation.simple","Identifier":"com.example.acttoken.act","Payload":{"StandardConfigurations":["com.example.acttoken"]}}`),
|
|
ConfigurationIdentifier: "com.example.acttoken",
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
insertHostDeclaration(t, hostUUID, decl.DeclarationUUID, "pending", "install", decl.Identifier)
|
|
|
|
manifest := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1)
|
|
advertised := manifest.Declarations.Activations[0].ServerToken
|
|
|
|
// The activation's token is its own, not the declaration's: otherwise
|
|
// editing the declaration would needlessly re-sync the activation.
|
|
require.NotEqual(t, manifest.Declarations.Configurations[0].ServerToken, advertised,
|
|
"a custom activation must not ride on the declaration's token")
|
|
|
|
// What the device fetches has to carry exactly what was advertised, or it
|
|
// re-fetches forever.
|
|
req := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: hostUUID}}
|
|
dm := mdm.DeclarativeManagement{}
|
|
dm.UDID = hostUUID
|
|
dm.Endpoint = "declaration/activation/com.example.acttoken.act"
|
|
served, err := ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(served, &body))
|
|
require.Equal(t, advertised, body["ServerToken"],
|
|
"the served activation token must match the manifest")
|
|
})
|
|
|
|
t.Run("ActivationTokenFoldsInVariablesUpdatedAt", func(t *testing.T) {
|
|
hostUUID, hardwareSerial := "test-host-uuid-acttok2", "ACT-TOK2"
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
|
|
teamID := uint(45)
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "ActTokDecl",
|
|
Identifier: "com.example.acttok",
|
|
TeamID: &teamID,
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.acttok","Payload":{"Enabled":true}}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
Activation: &fleet.MDMAppleCustomActivation{
|
|
Identifier: "com.example.acttok.act",
|
|
RawJSON: []byte(`{"Type":"com.apple.activation.simple","Identifier":"com.example.acttok.act","Payload":{"StandardConfigurations":["com.example.acttok"]}}`),
|
|
ConfigurationIdentifier: "com.example.acttok",
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
insertHostDeclaration(t, hostUUID, decl.DeclarationUUID, "pending", "install", decl.Identifier)
|
|
|
|
before := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1).Declarations.Activations[0].ServerToken
|
|
|
|
// A variable's value changing bumps variables_updated_at. The activation
|
|
// is expanded per host, so its token has to move too -- otherwise the host
|
|
// re-syncs, re-fetches the configuration, and keeps the stale activation.
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx,
|
|
`UPDATE host_mdm_apple_declarations SET variables_updated_at = ? WHERE host_uuid = ? AND declaration_uuid = ?`,
|
|
time.Now().UTC(), hostUUID, decl.DeclarationUUID)
|
|
return err
|
|
})
|
|
|
|
after := callDeclarativeManagementAndVerify(t, hostUUID, 1, 1).Declarations.Activations[0].ServerToken
|
|
require.NotEqual(t, before, after, "the activation token must change when variables_updated_at does")
|
|
|
|
// Delivery has to agree with the new advertised token.
|
|
req := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: hostUUID}}
|
|
dm := mdm.DeclarativeManagement{}
|
|
dm.UDID = hostUUID
|
|
dm.Endpoint = "declaration/activation/com.example.acttok.act"
|
|
served, err := ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(served, &body))
|
|
require.Equal(t, after, body["ServerToken"])
|
|
})
|
|
|
|
t.Run("ActivationVariablesCheckedWhenDeclarationHasNone", func(t *testing.T) {
|
|
hostUUID, hardwareSerial := "test-host-uuid-actvar", "ACT-VAR"
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
|
|
teamID := uint(44)
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "ActVarDecl",
|
|
Identifier: "com.example.actvar",
|
|
TeamID: &teamID,
|
|
// No variables in the declaration itself.
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.actvar","Payload":{"Enabled":true}}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
Activation: &fleet.MDMAppleCustomActivation{
|
|
Identifier: "com.example.actvar.act",
|
|
// ...but the activation references a vital that doesn't exist.
|
|
RawJSON: []byte(`{"Type":"com.apple.activation.simple","Identifier":"com.example.actvar.act","Payload":{"StandardConfigurations":["com.example.actvar"],"Predicate":"$FLEET_HOST_VITAL_999999 == 'x'"}}`),
|
|
ConfigurationIdentifier: "com.example.actvar",
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
insertHostDeclaration(t, hostUUID, decl.DeclarationUUID, "pending", "install", decl.Identifier)
|
|
|
|
// The activation's variables have to be checked on the activation itself.
|
|
// Gating on the declaration's variables_updated_at skipped this entirely,
|
|
// leaving the host with an activation it could never resolve.
|
|
manifest := callDeclarativeManagementAndVerify(t, hostUUID, 0, 0)
|
|
require.Empty(t, manifest.Declarations.Configurations)
|
|
require.Empty(t, manifest.Declarations.Activations)
|
|
|
|
var status string
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &status,
|
|
`SELECT status FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`,
|
|
hostUUID, decl.DeclarationUUID)
|
|
})
|
|
require.Equal(t, string(fleet.MDMDeliveryFailed), status)
|
|
})
|
|
|
|
t.Run("ManagementDeclarationRoutingAndEndpointGuard", func(t *testing.T) {
|
|
hostUUID := "test-host-uuid-mgmt"
|
|
hardwareSerial := "ABC123-MGMT"
|
|
|
|
createHost(t, hostUUID, hardwareSerial)
|
|
setupDeviceAndEnrollment(t, hostUUID, hardwareSerial)
|
|
|
|
mgmt, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "MgmtDecl",
|
|
Identifier: "com.example.mgmt",
|
|
RawJSON: []byte(`{"Type":"com.apple.management.organization-info","Identifier":"com.example.mgmt","Payload":{"Echo":"foo"}}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
insertHostDeclaration(t, hostUUID, mgmt.DeclarationUUID, "pending", "install", mgmt.Identifier)
|
|
|
|
// Management declarations are never activated, so no activation is
|
|
// synthesized and they don't appear under Configurations.
|
|
manifest := callDeclarativeManagementAndVerify(t, hostUUID, 0, 0)
|
|
require.Len(t, manifest.Declarations.Management, 1)
|
|
require.Equal(t, mgmt.Identifier, manifest.Declarations.Management[0].Identifier)
|
|
|
|
req := mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: hostUUID}}
|
|
dm := mdm.DeclarativeManagement{}
|
|
dm.UDID = hostUUID
|
|
dm.Endpoint = "declaration/management/" + mgmt.Identifier
|
|
response, err := ddmService.DeclarativeManagement(&req, &dm)
|
|
require.NoError(t, err)
|
|
var served map[string]any
|
|
require.NoError(t, json.Unmarshal(response, &served))
|
|
require.Equal(t, "com.apple.management.organization-info", served["Type"])
|
|
|
|
// The two endpoints must not serve each other's rows.
|
|
dm.Endpoint = "declaration/configuration/" + mgmt.Identifier
|
|
_, err = ddmService.DeclarativeManagement(&req, &dm)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
// osUpdatesDeclContents is a minimal DDM software-update declaration body that
|
|
// references both host-target OS Fleet variables. It is used by the OS-updates
|
|
// DDM sync tests below.
|
|
const osUpdatesDeclContents = `{
|
|
"Type": "com.apple.configuration.softwareupdate.enforcement.specific",
|
|
"Identifier": "com.fleetdm.fleet.mdm.os-updates.macos",
|
|
"Payload": {
|
|
"TargetOSVersion": "$FLEET_VAR_HOST_TARGET_OS_VERSION",
|
|
"TargetLocalDateTime": "${FLEET_VAR_HOST_TARGET_OS_DEADLINE}T12:00:00"
|
|
}
|
|
}`
|
|
|
|
// TestReplaceDeclarationFleetVariablesOSUpdateTargets covers the variable
|
|
// resolution branch for the OS-update target variables:
|
|
// - the tracking row exists but the target hasn't been computed yet -> the
|
|
// declaration is deferred with a notReadyYetError (so the caller marks it
|
|
// pending, not failed),
|
|
// - the target is set -> the version and RFC3339 deadline are substituted,
|
|
// - there is no tracking row at all -> a hard error (marked failed).
|
|
func TestReplaceDeclarationFleetVariablesOSUpdateTargets(t *testing.T) {
|
|
ctx := t.Context()
|
|
ds := mysqltest.CreateMySQLDS(t)
|
|
svc := MDMAppleDDMService{
|
|
ds: ds,
|
|
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
|
|
}
|
|
|
|
newHost := func(hostUUID string) *fleet.Host {
|
|
h, err := ds.NewHost(ctx, &fleet.Host{
|
|
UUID: hostUUID,
|
|
Hostname: hostUUID,
|
|
OsqueryHostID: new(hostUUID),
|
|
NodeKey: new(hostUUID),
|
|
Platform: "darwin",
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
return h
|
|
}
|
|
|
|
t.Run("target not yet computed defers with notReadyYetError", func(t *testing.T) {
|
|
h := newHost("os-var-notready")
|
|
// A tracking row exists (device id captured) but no target has been set.
|
|
require.NoError(t, ds.InsertAppleSoftwareUpdateDeviceID(ctx, h.UUID, "Mac14,2"))
|
|
|
|
_, err := svc.replaceDeclarationFleetVariables(ctx, osUpdatesDeclContents, h.UUID)
|
|
var notReady notReadyYetError
|
|
require.ErrorAs(t, err, ¬Ready)
|
|
require.Contains(t, notReady.Message, "not yet available")
|
|
require.Contains(t, notReady.Message, "resend this profile once available")
|
|
})
|
|
|
|
t.Run("target set substitutes version and DateOnly noon deadline", func(t *testing.T) {
|
|
h := newHost("os-var-ready")
|
|
require.NoError(t, ds.InsertAppleSoftwareUpdateDeviceID(ctx, h.UUID, "Mac14,2"))
|
|
|
|
deadline, _ := time.Parse(time.DateOnly, time.Now().UTC().Add(48*time.Hour).Format(time.DateOnly))
|
|
resolvedAt := time.Now().UTC().Truncate(time.Microsecond)
|
|
require.NoError(t, ds.SetAppleOSUpdateTargetsAndResend(ctx, []*fleet.ComputedAppleSoftwareUpdateHost{{
|
|
AppleSoftwareUpdateHost: fleet.AppleSoftwareUpdateHost{
|
|
HostUUID: h.UUID, TargetOSVersion: "15.1", TargetDeadline: &deadline, ResolvedAt: &resolvedAt,
|
|
},
|
|
}}))
|
|
|
|
out, err := svc.replaceDeclarationFleetVariables(ctx, osUpdatesDeclContents, h.UUID)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, out, "$FLEET_VAR")
|
|
|
|
deadline = deadline.Add(12 * time.Hour) // noon local time
|
|
|
|
var parsed struct {
|
|
Payload struct {
|
|
TargetOSVersion string
|
|
TargetLocalDateTime string
|
|
}
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(out), &parsed))
|
|
require.Equal(t, "15.1", parsed.Payload.TargetOSVersion)
|
|
gotDeadline, err := time.Parse("2006-01-02T15:04:05", parsed.Payload.TargetLocalDateTime)
|
|
require.NoError(t, err)
|
|
require.True(t, deadline.Equal(gotDeadline), "want %s got %s", deadline, gotDeadline)
|
|
})
|
|
|
|
t.Run("missing tracking row is a hard error, not a defer", func(t *testing.T) {
|
|
h := newHost("os-var-missing") // no InsertAppleSoftwareUpdateDeviceID -> no tracking row
|
|
|
|
_, err := svc.replaceDeclarationFleetVariables(ctx, osUpdatesDeclContents, h.UUID)
|
|
require.Error(t, err)
|
|
var notReady notReadyYetError
|
|
require.NotErrorAs(t, err, ¬Ready, "a missing tracking row must not defer")
|
|
require.Contains(t, err.Error(), "not found")
|
|
})
|
|
}
|
|
|
|
// TestDeclarativeManagementOSUpdatesPendingThenResolved exercises the full
|
|
// DDM-sync piece end to end at the handler layer: an OS-update declaration whose
|
|
// target variables can't be resolved yet is marked pending (with a user-facing
|
|
// detail) and excluded from the manifest; then, once the cron's datastore write
|
|
// sets the target and bumps the declaration for resend, the same fetches resolve
|
|
// and the declaration is served with concrete values.
|
|
func TestDeclarativeManagementOSUpdatesPendingThenResolved(t *testing.T) {
|
|
ctx := t.Context()
|
|
ds := mysqltest.CreateMySQLDS(t)
|
|
svc := MDMAppleDDMService{
|
|
ds: ds,
|
|
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
|
|
}
|
|
|
|
const (
|
|
hostUUID = "os-updates-ddm-host"
|
|
deviceID = "Mac14,2"
|
|
declIdentifier = "com.fleetdm.fleet.mdm.os-updates.macos"
|
|
)
|
|
|
|
_, err := ds.NewHost(ctx, &fleet.Host{
|
|
UUID: hostUUID,
|
|
Hostname: hostUUID,
|
|
OsqueryHostID: new(hostUUID),
|
|
NodeKey: new(hostUUID),
|
|
Platform: "darwin",
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Fleet-managed OS-updates DDM declaration referencing the two target vars.
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "os-updates-decl-uuid",
|
|
Name: fleetmdm.FleetMacOSUpdatesProfileName,
|
|
Identifier: declIdentifier,
|
|
RawJSON: []byte(osUpdatesDeclContents),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
}, []fleet.FleetVarName{fleet.FleetVarHostTargetOSVersion, fleet.FleetVarHostTargetOSDeadline})
|
|
require.NoError(t, err)
|
|
|
|
// Assign the declaration to the host. variables_updated_at is set (as the
|
|
// reconciler would) so the sync path attempts variable resolution; status
|
|
// starts NULL (freshly assigned, not yet delivered).
|
|
initialVarsUpdated := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond)
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
var token string
|
|
if err := sqlx.GetContext(ctx, q, &token,
|
|
"SELECT HEX(token) FROM mdm_apple_declarations WHERE declaration_uuid = ?", decl.DeclarationUUID); err != nil {
|
|
return err
|
|
}
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO host_mdm_apple_declarations
|
|
(host_uuid, declaration_uuid, status, operation_type, token, declaration_identifier, declaration_name, scope, variables_updated_at)
|
|
VALUES (?, ?, NULL, 'install', UNHEX(?), ?, ?, 'System', ?)`,
|
|
hostUUID, decl.DeclarationUUID, token, declIdentifier, fleetmdm.FleetMacOSUpdatesProfileName, initialVarsUpdated)
|
|
return err
|
|
})
|
|
|
|
// Tracking row exists (device id captured) but the target isn't computed yet.
|
|
require.NoError(t, ds.InsertAppleSoftwareUpdateDeviceID(ctx, hostUUID, deviceID))
|
|
|
|
readHostDecl := func() (status *string, detail string) {
|
|
var row struct {
|
|
Status *string `db:"status"`
|
|
Detail string `db:"detail"`
|
|
}
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &row,
|
|
`SELECT status, COALESCE(detail, '') AS detail FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`,
|
|
hostUUID, decl.DeclarationUUID)
|
|
})
|
|
return row.Status, row.Detail
|
|
}
|
|
readVarsUpdatedAt := func() *time.Time {
|
|
var vals []time.Time
|
|
mysqltest.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &vals,
|
|
`SELECT variables_updated_at FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ? AND variables_updated_at IS NOT NULL`,
|
|
hostUUID, decl.DeclarationUUID)
|
|
})
|
|
if len(vals) == 0 {
|
|
return nil
|
|
}
|
|
return &vals[0]
|
|
}
|
|
configItems := func() []fleet.MDMAppleDDMManifest {
|
|
body, err := svc.handleDeclarationItems(ctx, hostUUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
var resp fleet.MDMAppleDDMDeclarationItemsResponse
|
|
require.NoError(t, json.Unmarshal(body, &resp))
|
|
return resp.Declarations.Configurations
|
|
}
|
|
|
|
configParts := []string{"declaration", "configuration", declIdentifier}
|
|
|
|
// === Phase 1: target not ready -> pending with detail, excluded from manifest ===
|
|
|
|
body, err := svc.handleConfigurationDeclaration(ctx, configParts, hostUUID, fleet.PayloadScopeSystem, false)
|
|
require.NoError(t, err)
|
|
require.Nil(t, body, "an unresolvable declaration is served as an empty 200")
|
|
|
|
status, detail := readHostDecl()
|
|
require.NotNil(t, status)
|
|
require.Equal(t, string(fleet.MDMDeliveryPending), *status)
|
|
require.Contains(t, detail, "not yet available")
|
|
|
|
for _, c := range configItems() {
|
|
require.NotEqual(t, declIdentifier, c.Identifier, "unresolvable declaration must be excluded from the manifest")
|
|
}
|
|
// handleDeclarationItems also keeps it pending with the detail.
|
|
status, detail = readHostDecl()
|
|
require.NotNil(t, status)
|
|
require.Equal(t, string(fleet.MDMDeliveryPending), *status)
|
|
require.Contains(t, detail, "not yet available")
|
|
|
|
// === Phase 2: cron computes the target and bumps the declaration for resend ===
|
|
|
|
deadline, _ := time.Parse(time.DateOnly, time.Now().UTC().Add(48*time.Hour).Format(time.DateOnly))
|
|
deadline = deadline.Add(12 * time.Hour) // noon local time
|
|
resolvedAt := time.Now().UTC().Truncate(time.Microsecond)
|
|
require.NoError(t, ds.SetAppleOSUpdateTargetsAndResend(ctx, []*fleet.ComputedAppleSoftwareUpdateHost{{
|
|
AppleSoftwareUpdateHost: fleet.AppleSoftwareUpdateHost{
|
|
HostUUID: hostUUID, TargetOSVersion: "15.1", TargetDeadline: &deadline, ResolvedAt: &resolvedAt,
|
|
},
|
|
Resend: true,
|
|
}}))
|
|
|
|
// The resend signal: status cleared to NULL and variables_updated_at bumped.
|
|
status, _ = readHostDecl()
|
|
require.Nil(t, status, "resend resets status to NULL")
|
|
bumped := readVarsUpdatedAt()
|
|
require.NotNil(t, bumped)
|
|
require.True(t, bumped.After(initialVarsUpdated), "variables_updated_at should be bumped forward for resend")
|
|
|
|
// === Phase 3: the declaration now resolves and is served with concrete values ===
|
|
|
|
body, err = svc.handleConfigurationDeclaration(ctx, configParts, hostUUID, fleet.PayloadScopeSystem, false)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, body)
|
|
require.NotContains(t, string(body), "$FLEET_VAR")
|
|
|
|
var served struct {
|
|
Identifier string
|
|
Payload struct {
|
|
TargetOSVersion string
|
|
TargetLocalDateTime string
|
|
}
|
|
ServerToken string
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &served))
|
|
require.Equal(t, declIdentifier, served.Identifier)
|
|
require.Equal(t, "15.1", served.Payload.TargetOSVersion)
|
|
require.NotEmpty(t, served.ServerToken)
|
|
gotDeadline, err := time.Parse("2006-01-02T15:04:05", served.Payload.TargetLocalDateTime)
|
|
require.NoError(t, err)
|
|
require.True(t, deadline.Equal(gotDeadline), "want %s got %s", deadline, gotDeadline)
|
|
|
|
// And it is now included in the declaration-items manifest.
|
|
found := false
|
|
for _, c := range configItems() {
|
|
if c.Identifier == declIdentifier {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "resolved OS-updates declaration should be present in declaration-items")
|
|
}
|