<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #48570 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] 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. Added in a previous PR - [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. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added end-to-end Apple DDM asset support in GitOps, including export and GitOps parsing for `macOS settings` assets. * Introduced Apple DDM asset management APIs (list/get/download/create/delete) plus a batch set operation with dry-run. * **Bug Fixes** * Improved Apple MDM/DDM reconciliation so referenced asset updates trigger re-delivery via asset-aware tokening. * Added safer validation around asset type changes and deletion conflicts when assets are still referenced. * **Tests** * Expanded unit and integration coverage for asset parsing, upload/apply behavior, reconciliation, and access control. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2243 lines
88 KiB
Go
2243 lines
88 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5" // nolint:gosec // used only for tests
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
|
"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/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func (s *integrationMDMTestSuite) TestAppleDDMBatchUpload() {
|
|
t := s.T()
|
|
tmpl := `
|
|
{
|
|
"Type": "com.apple.configuration.decl%d",
|
|
"Identifier": "com.fleet.config%d",
|
|
"Payload": {
|
|
"ServiceType": "com.apple.bash" %s
|
|
}
|
|
}`
|
|
|
|
newDeclBytes := func(i int, payload ...string) []byte {
|
|
var p string
|
|
if len(payload) > 0 {
|
|
p = "," + strings.Join(payload, ",")
|
|
}
|
|
return []byte(fmt.Sprintf(tmpl, i, i, p))
|
|
}
|
|
|
|
var decls [][]byte
|
|
|
|
for i := 0; i < 7; i++ {
|
|
decls = append(decls, newDeclBytes(i))
|
|
}
|
|
|
|
// Non-configuration type should fail
|
|
res := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "bad", Contents: []byte(`{"Type": "com.apple.activation", "Payload": "test"}`)},
|
|
}}, http.StatusUnprocessableEntity)
|
|
|
|
errMsg := extractServerErrorText(res.Body)
|
|
require.Contains(t, errMsg, "Only configuration declarations (com.apple.configuration.) are supported")
|
|
|
|
// Types from our list of forbidden types should fail
|
|
for ft := range fleet.ForbiddenDeclTypes {
|
|
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "bad2", Contents: []byte(fmt.Sprintf(`{"Type": "%s", "Payload": "test"}`, ft))},
|
|
}}, http.StatusUnprocessableEntity)
|
|
|
|
errMsg = extractServerErrorText(res.Body)
|
|
require.Contains(t, errMsg, "is a forbidden declaration")
|
|
}
|
|
|
|
// "com.apple.configuration.management.status-subscriptions" type should fail
|
|
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "bad2", Contents: []byte(`{"Type": "com.apple.configuration.management.status-subscriptions", "Payload": "test"}`)},
|
|
}}, http.StatusUnprocessableEntity)
|
|
|
|
errMsg = extractServerErrorText(res.Body)
|
|
require.Contains(t, errMsg, "Declaration profile can't include status subscription type. To get host's vitals, please use queries and policies.")
|
|
|
|
// Two different payloads with the same name should fail
|
|
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "bad2", Contents: newDeclBytes(1, `"foo": "bar"`)},
|
|
{Name: "bad2", Contents: newDeclBytes(2, `"baz": "bing"`)},
|
|
}}, http.StatusUnprocessableEntity)
|
|
errMsg = extractServerErrorText(res.Body)
|
|
require.Contains(t, errMsg, "More than one configuration profile have the same name")
|
|
|
|
// Same identifier should fail
|
|
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1", Contents: decls[0]},
|
|
{Name: "N2", Contents: decls[0]},
|
|
}}, http.StatusUnprocessableEntity)
|
|
errMsg = extractServerErrorText(res.Body)
|
|
require.Contains(t, errMsg, "A declaration profile with this identifier already exists.")
|
|
|
|
// Create 2 declarations
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1", Contents: decls[0]},
|
|
{Name: "N2", Contents: decls[1]},
|
|
}}, http.StatusNoContent)
|
|
|
|
var resp listMDMConfigProfilesResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
|
|
require.Len(t, resp.Profiles, 2)
|
|
require.Equal(t, "N1", resp.Profiles[0].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[0].Platform)
|
|
require.Equal(t, "N2", resp.Profiles[1].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[1].Platform)
|
|
|
|
// Create 2 new declarations. These should take the place of the first two.
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N3", Contents: decls[2]},
|
|
{Name: "N4", Contents: decls[3]},
|
|
}}, http.StatusNoContent)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
|
|
require.Len(t, resp.Profiles, 2)
|
|
require.Equal(t, "N3", resp.Profiles[0].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[0].Platform)
|
|
require.Equal(t, "N4", resp.Profiles[1].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[1].Platform)
|
|
|
|
// replace only 1 declaration, the other one should be the same
|
|
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N3", Contents: decls[2]},
|
|
{Name: "N5", Contents: decls[4]},
|
|
}}, http.StatusNoContent)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
|
|
require.Len(t, resp.Profiles, 2)
|
|
require.Equal(t, "N3", resp.Profiles[0].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[0].Platform)
|
|
require.Equal(t, "N5", resp.Profiles[1].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[1].Platform)
|
|
|
|
// update the declarations
|
|
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N3", Contents: newDeclBytes(2, `"foo": "bar"`)},
|
|
{Name: "N5", Contents: newDeclBytes(4, `"bing": "baz"`)},
|
|
}}, http.StatusNoContent)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
|
|
require.Len(t, resp.Profiles, 2)
|
|
require.Equal(t, "N3", resp.Profiles[0].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[0].Platform)
|
|
require.Equal(t, "N5", resp.Profiles[1].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[1].Platform)
|
|
|
|
var createResp fleet.CreateLabelResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label_1", Query: "select 1"}, http.StatusOK, &createResp)
|
|
require.NotZero(t, createResp.Label.ID)
|
|
require.Equal(t, "label_1", createResp.Label.Name)
|
|
lbl1 := createResp.Label.Label
|
|
|
|
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label_2", Query: "select 1"}, http.StatusOK, &createResp)
|
|
require.NotZero(t, createResp.Label.ID)
|
|
require.Equal(t, "label_2", createResp.Label.Name)
|
|
lbl2 := createResp.Label.Label
|
|
|
|
// Add with the deprecated "labels" and the new LabelsIncludeAll field
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N5", Contents: decls[5], Labels: []string{lbl1.Name, lbl2.Name}},
|
|
{Name: "N6", Contents: decls[6], LabelsIncludeAll: []string{lbl1.Name}},
|
|
}}, http.StatusNoContent)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
|
|
require.Len(t, resp.Profiles, 2)
|
|
require.Equal(t, "N5", resp.Profiles[0].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[0].Platform)
|
|
require.Equal(t, "N6", resp.Profiles[1].Name)
|
|
require.Equal(t, "darwin", resp.Profiles[1].Platform)
|
|
require.Len(t, resp.Profiles[0].LabelsIncludeAll, 2)
|
|
require.Equal(t, lbl1.Name, resp.Profiles[0].LabelsIncludeAll[0].LabelName)
|
|
require.Equal(t, lbl2.Name, resp.Profiles[0].LabelsIncludeAll[1].LabelName)
|
|
require.Len(t, resp.Profiles[1].LabelsIncludeAll, 1)
|
|
require.Equal(t, lbl1.Name, resp.Profiles[1].LabelsIncludeAll[0].LabelName)
|
|
|
|
// PayloadScope handling via the batch/GitOps path: the top-level PayloadScope
|
|
// drives the scope column. The key is intentionally kept in the stored
|
|
// raw_json (it's stripped only at delivery), so the declaration round-trips.
|
|
// A declaration with no PayloadScope defaults to the device channel.
|
|
userScoped := []byte(`{"Type":"com.apple.configuration.foo","Identifier":"com.fleet.userscoped","PayloadScope":"User","Payload":{"Enabled":true}}`)
|
|
deviceScoped := []byte(`{"Type":"com.apple.configuration.bar","Identifier":"com.fleet.devicescoped","Payload":{"Enabled":true}}`)
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "UserScoped", Contents: userScoped},
|
|
{Name: "DeviceScoped", Contents: deviceScoped},
|
|
}}, http.StatusNoContent)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
require.Len(t, resp.Profiles, 2)
|
|
uuidByName := make(map[string]string, len(resp.Profiles))
|
|
for _, p := range resp.Profiles {
|
|
uuidByName[p.Name] = p.ProfileUUID
|
|
}
|
|
|
|
userDeclDB, err := s.ds.GetMDMAppleDeclaration(context.Background(), uuidByName["UserScoped"])
|
|
require.NoError(t, err)
|
|
require.Equal(t, fleet.PayloadScopeUser, userDeclDB.Scope)
|
|
|
|
deviceDeclDB, err := s.ds.GetMDMAppleDeclaration(context.Background(), uuidByName["DeviceScoped"])
|
|
require.NoError(t, err)
|
|
require.Equal(t, fleet.PayloadScopeSystem, deviceDeclDB.Scope)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestMDMAppleDeviceManagementRequests() {
|
|
t := s.T()
|
|
_, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
calcChecksum := func(source []byte) string {
|
|
csum := fmt.Sprintf("%x", md5.Sum(source)) //nolint:gosec
|
|
return strings.ToUpper(csum)
|
|
}
|
|
|
|
insertDeclaration := func(t *testing.T, decl fleet.MDMAppleDeclaration) {
|
|
stmt := `
|
|
INSERT INTO mdm_apple_declarations (
|
|
declaration_uuid,
|
|
team_id,
|
|
identifier,
|
|
name,
|
|
raw_json,
|
|
created_at,
|
|
uploaded_at
|
|
) VALUES (?,?,?,?,?,?,?)`
|
|
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(context.Background(), stmt,
|
|
decl.DeclarationUUID,
|
|
decl.TeamID,
|
|
decl.Identifier,
|
|
decl.Name,
|
|
decl.RawJSON,
|
|
decl.CreatedAt,
|
|
decl.UploadedAt,
|
|
)
|
|
return err
|
|
})
|
|
}
|
|
|
|
insertHostDeclaration := func(t *testing.T, hostUUID string, decl fleet.MDMAppleDeclaration) {
|
|
stmt := `
|
|
INSERT INTO host_mdm_apple_declarations (
|
|
host_uuid,
|
|
status,
|
|
operation_type,
|
|
token,
|
|
declaration_uuid,
|
|
declaration_identifier
|
|
) VALUES (?,?,?,UNHEX(?),?,?)`
|
|
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(context.Background(), stmt,
|
|
hostUUID,
|
|
fleet.MDMDeliveryPending,
|
|
fleet.MDMOperationTypeInstall,
|
|
calcChecksum(decl.RawJSON),
|
|
decl.DeclarationUUID,
|
|
decl.Identifier,
|
|
)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// initialize a time to use for our first declaration, subsequent declarations will be
|
|
// incremented by a minute
|
|
then := time.Now().UTC().Truncate(time.Second).Add(-1 * time.Hour)
|
|
|
|
// insert a declaration with no team
|
|
noTeamDeclsByUUID := map[string]fleet.MDMAppleDeclaration{
|
|
"123": {
|
|
DeclarationUUID: "123",
|
|
TeamID: ptr.Uint(0),
|
|
Identifier: "com.example",
|
|
Name: "Example",
|
|
RawJSON: json.RawMessage(`{
|
|
"Type": "com.apple.configuration.declaration-items.test",
|
|
"Payload": {"foo":"bar"},
|
|
"Identifier": "com.example"
|
|
}`),
|
|
CreatedAt: then,
|
|
UploadedAt: then,
|
|
},
|
|
}
|
|
insertDeclaration(t, noTeamDeclsByUUID["123"])
|
|
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["123"])
|
|
|
|
mapDeclsByChecksum := func(byUUID map[string]fleet.MDMAppleDeclaration) map[string]fleet.MDMAppleDeclaration {
|
|
byChecksum := make(map[string]fleet.MDMAppleDeclaration)
|
|
for _, d := range byUUID {
|
|
byChecksum[calcChecksum(d.RawJSON)] = byUUID[d.DeclarationUUID]
|
|
}
|
|
return byChecksum
|
|
}
|
|
|
|
assertDeclarationResponse := func(r *http.Response, expected fleet.MDMAppleDeclaration) {
|
|
require.NotNil(t, r)
|
|
|
|
// unmarsal the response and assert it's valid
|
|
var wantParsed fleet.MDMAppleDDMDeclarationResponse
|
|
require.NoError(t, json.Unmarshal(expected.RawJSON, &wantParsed))
|
|
var gotParsed fleet.MDMAppleDDMDeclarationResponse
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
require.EqualValues(t, wantParsed.Payload, gotParsed.Payload)
|
|
require.Equal(t, calcChecksum(expected.RawJSON), gotParsed.ServerToken)
|
|
require.Equal(t, expected.Identifier, gotParsed.Identifier)
|
|
// t.Logf("decoded: %+v", gotParsed)
|
|
}
|
|
|
|
checkTokensResp := func(t *testing.T, r fleet.MDMAppleDDMTokensResponse, expectedTimestamp time.Time, prevToken string) {
|
|
require.Equal(t, expectedTimestamp, r.SyncTokens.Timestamp)
|
|
require.NotEmpty(t, r.SyncTokens.DeclarationsToken)
|
|
require.NotEqual(t, prevToken, r.SyncTokens.DeclarationsToken)
|
|
}
|
|
|
|
checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string, expectedDeclsByChecksum map[string]fleet.MDMAppleDeclaration) {
|
|
require.Equal(t, expectedDeclTok, r.DeclarationsToken)
|
|
// TODO(roberto): better assertions
|
|
require.NotEmpty(t, r.Declarations.Activations)
|
|
require.Empty(t, r.Declarations.Assets)
|
|
require.Empty(t, r.Declarations.Management)
|
|
require.Len(t, r.Declarations.Configurations, len(expectedDeclsByChecksum))
|
|
for _, m := range r.Declarations.Configurations {
|
|
d, ok := expectedDeclsByChecksum[m.ServerToken]
|
|
require.True(t, ok)
|
|
require.Equal(t, d.Identifier, m.Identifier)
|
|
}
|
|
}
|
|
|
|
var currDeclToken string // we'll use this to track the expected token across tests
|
|
|
|
t.Run("Tokens", func(t *testing.T) {
|
|
// get tokens, timestamp should be the same as the declaration and token should be non-empty
|
|
r, err := mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
parsed := parseTokensResp(t, r)
|
|
checkTokensResp(t, parsed, then, "")
|
|
currDeclToken = parsed.SyncTokens.DeclarationsToken
|
|
|
|
// insert a new declaration
|
|
noTeamDeclsByUUID["456"] = fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "456",
|
|
TeamID: ptr.Uint(0),
|
|
Identifier: "com.example2",
|
|
Name: "Example2",
|
|
RawJSON: json.RawMessage(`{
|
|
"Type": "com.apple.configuration.declaration-items.test",
|
|
"Payload": {"foo":"baz"},
|
|
"Identifier": "com.example2"
|
|
}`),
|
|
CreatedAt: then.Add(1 * time.Minute),
|
|
UploadedAt: then.Add(1 * time.Minute),
|
|
}
|
|
insertDeclaration(t, noTeamDeclsByUUID["456"])
|
|
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["456"])
|
|
|
|
// get tokens again, timestamp and token should have changed
|
|
r, err = mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
parsed = parseTokensResp(t, r)
|
|
checkTokensResp(t, parsed, then.Add(1*time.Minute), currDeclToken)
|
|
currDeclToken = parsed.SyncTokens.DeclarationsToken
|
|
})
|
|
|
|
t.Run("DeclarationItems", func(t *testing.T) {
|
|
r, err := mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
checkDeclarationItemsResp(t, parseDeclarationItemsResp(t, r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
|
|
|
|
// insert a new declaration
|
|
noTeamDeclsByUUID["789"] = fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "789",
|
|
TeamID: ptr.Uint(0),
|
|
Identifier: "com.example3",
|
|
Name: "Example3",
|
|
RawJSON: json.RawMessage(`{
|
|
"Type": "com.apple.configuration.declaration-items.test",
|
|
"Payload": {"foo":"bang"},
|
|
"Identifier": "com.example3"
|
|
}`),
|
|
CreatedAt: then.Add(2 * time.Minute),
|
|
UploadedAt: then.Add(2 * time.Minute),
|
|
}
|
|
insertDeclaration(t, noTeamDeclsByUUID["789"])
|
|
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["789"])
|
|
|
|
// get tokens again, timestamp and token should have changed
|
|
r, err = mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
toks := parseTokensResp(t, r)
|
|
checkTokensResp(t, toks, then.Add(2*time.Minute), currDeclToken)
|
|
currDeclToken = toks.SyncTokens.DeclarationsToken
|
|
|
|
r, err = mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
checkDeclarationItemsResp(t, parseDeclarationItemsResp(t, r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
|
|
})
|
|
|
|
t.Run("Status", func(t *testing.T) {
|
|
_, err := mdmDevice.DeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("Declaration", func(t *testing.T) {
|
|
want := noTeamDeclsByUUID["123"]
|
|
declarationPath := fmt.Sprintf("declaration/%s/%s", "configuration", want.Identifier)
|
|
r, err := mdmDevice.DeclarativeManagement(declarationPath)
|
|
require.NoError(t, err)
|
|
|
|
assertDeclarationResponse(r, want)
|
|
|
|
// insert a new declaration
|
|
noTeamDeclsByUUID["abc"] = fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "abc",
|
|
TeamID: ptr.Uint(0),
|
|
Identifier: "com.example4",
|
|
Name: "Example4",
|
|
RawJSON: json.RawMessage(`{
|
|
"Type": "com.apple.configuration.test",
|
|
"Payload": {"foo":"bar"},
|
|
"Identifier": "com.example4"
|
|
}`),
|
|
CreatedAt: then.Add(3 * time.Minute),
|
|
UploadedAt: then.Add(3 * time.Minute),
|
|
}
|
|
insertDeclaration(t, noTeamDeclsByUUID["abc"])
|
|
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["abc"])
|
|
want = noTeamDeclsByUUID["abc"]
|
|
r, err = mdmDevice.DeclarativeManagement(fmt.Sprintf("declaration/%s/%s", "configuration", want.Identifier))
|
|
require.NoError(t, err)
|
|
|
|
// try getting a non-existent declaration, should fail 404
|
|
nonExistantDeclarationPath := fmt.Sprintf("declaration/%s/%s", "configuration", "nonexistent")
|
|
_, err = mdmDevice.DeclarativeManagement(nonExistantDeclarationPath)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "404 Not Found")
|
|
|
|
// try getting an unsupported declaration, should fail 404
|
|
unsupportedDeclarationPath := fmt.Sprintf("declaration/%s/%s", "asset", "nonexistent")
|
|
_, err = mdmDevice.DeclarativeManagement(unsupportedDeclarationPath)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "404 Not Found")
|
|
|
|
// typo should fail as bad request
|
|
typoDeclarationPath := fmt.Sprintf("declarations/%s/%s", "configurations", want.Identifier)
|
|
_, err = mdmDevice.DeclarativeManagement(typoDeclarationPath)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "400 Bad Request")
|
|
|
|
assertDeclarationResponse(r, want)
|
|
})
|
|
}
|
|
|
|
func parseTokensResp(t *testing.T, r *http.Response) fleet.MDMAppleDDMTokensResponse {
|
|
require.NotNil(t, r)
|
|
b, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
defer r.Body.Close()
|
|
r.Body = io.NopCloser(bytes.NewBuffer(b))
|
|
|
|
// unmarshal the response to make sure it's valid
|
|
var tok fleet.MDMAppleDDMTokensResponse
|
|
err = json.NewDecoder(r.Body).Decode(&tok)
|
|
require.NoError(t, err)
|
|
|
|
return tok
|
|
}
|
|
|
|
func parseDeclarationItemsResp(t *testing.T, r *http.Response) fleet.MDMAppleDDMDeclarationItemsResponse {
|
|
require.NotNil(t, r)
|
|
b, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
defer r.Body.Close()
|
|
r.Body = io.NopCloser(bytes.NewBuffer(b))
|
|
|
|
// unmarshal the response to make sure it's valid
|
|
var di fleet.MDMAppleDDMDeclarationItemsResponse
|
|
err = json.NewDecoder(r.Body).Decode(&di)
|
|
require.NoError(t, err)
|
|
|
|
return di
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestAppleDDMSecretVariables() {
|
|
t := s.T()
|
|
_, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string,
|
|
expectedDeclsByToken map[string]fleet.MDMAppleDeclaration,
|
|
) {
|
|
require.Equal(t, expectedDeclTok, r.DeclarationsToken)
|
|
require.NotEmpty(t, r.Declarations.Activations)
|
|
require.Empty(t, r.Declarations.Assets)
|
|
require.Empty(t, r.Declarations.Management)
|
|
require.Len(t, r.Declarations.Configurations, len(expectedDeclsByToken))
|
|
for _, m := range r.Declarations.Configurations {
|
|
d, ok := expectedDeclsByToken[m.ServerToken]
|
|
if !ok {
|
|
for k := range expectedDeclsByToken {
|
|
t.Logf("expected token: %x", k)
|
|
}
|
|
}
|
|
require.True(t, ok, "server token %x not found for %s", m.ServerToken, m.Identifier)
|
|
require.Equal(t, d.Identifier, m.Identifier)
|
|
}
|
|
}
|
|
|
|
tmpl := `
|
|
{
|
|
"Type": "com.apple.configuration.decl%d",
|
|
"Identifier": "com.fleet.config%d",
|
|
"Payload": {
|
|
"ServiceType": "com.apple.bash%d" %s
|
|
}
|
|
}`
|
|
|
|
newDeclBytes := func(i int, payload ...string) []byte {
|
|
var p string
|
|
if len(payload) > 0 {
|
|
p = "," + strings.Join(payload, ",")
|
|
}
|
|
return fmt.Appendf(nil, tmpl, i, i, i, p)
|
|
}
|
|
|
|
var decls [][]byte
|
|
for i := range 2 {
|
|
decls = append(decls, newDeclBytes(i))
|
|
}
|
|
// Use secrets
|
|
myBash := "com.apple.bash1"
|
|
decls[1] = []byte(strings.ReplaceAll(string(decls[1]), myBash, "$"+fleet.ServerSecretPrefix+"BASH"))
|
|
|
|
// Create declarations
|
|
profilesReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N0", Contents: decls[0]},
|
|
{Name: "N1", Contents: decls[1]},
|
|
}}
|
|
// First dry run
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, "dry_run", "true")
|
|
|
|
var resp listMDMConfigProfilesResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
require.Empty(t, resp.Profiles)
|
|
|
|
// Add secrets to server
|
|
req := fleet.CreateSecretVariablesRequest{
|
|
SecretVariables: []fleet.SecretVariable{
|
|
{
|
|
Name: "FLEET_SECRET_BASH",
|
|
Value: myBash,
|
|
},
|
|
},
|
|
}
|
|
secretResp := fleet.CreateSecretVariablesResponse{}
|
|
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
|
|
|
|
// Now real run
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent)
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
|
|
require.Len(t, resp.Profiles, len(decls))
|
|
checkedProfiles := 0
|
|
for _, p := range resp.Profiles {
|
|
switch p.Name {
|
|
case "N0", "N1":
|
|
require.Equal(t, "darwin", p.Platform)
|
|
checkedProfiles++
|
|
default:
|
|
t.Logf("unexpected profile %s", p.Name)
|
|
}
|
|
}
|
|
assert.Equal(t, len(decls), checkedProfiles)
|
|
|
|
getDeclaration := func(t *testing.T, name string) fleet.MDMAppleDeclaration {
|
|
stmt := `
|
|
SELECT
|
|
declaration_uuid,
|
|
team_id,
|
|
identifier,
|
|
name,
|
|
raw_json,
|
|
HEX(token) as token,
|
|
created_at,
|
|
uploaded_at,
|
|
secrets_updated_at
|
|
FROM mdm_apple_declarations
|
|
WHERE name = ?`
|
|
|
|
var decl fleet.MDMAppleDeclaration
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(context.Background(), q, &decl, stmt, name)
|
|
})
|
|
return decl
|
|
}
|
|
nameToIdentifier := make(map[string]string, 3)
|
|
nameToUUID := make(map[string]string, 3)
|
|
declsByToken := map[string]fleet.MDMAppleDeclaration{}
|
|
decl := getDeclaration(t, "N0")
|
|
nameToIdentifier["N0"] = decl.Identifier
|
|
nameToUUID["N0"] = decl.DeclarationUUID
|
|
declsByToken[decl.Token] = fleet.MDMAppleDeclaration{
|
|
Identifier: "com.fleet.config0",
|
|
}
|
|
decl = getDeclaration(t, "N1")
|
|
assert.NotContains(t, string(decl.RawJSON), myBash)
|
|
assert.Contains(t, string(decl.RawJSON), "$"+fleet.ServerSecretPrefix+"BASH")
|
|
nameToIdentifier["N1"] = decl.Identifier
|
|
nameToUUID["N1"] = decl.DeclarationUUID
|
|
n1Token := decl.Token
|
|
declsByToken[decl.Token] = fleet.MDMAppleDeclaration{
|
|
Identifier: "com.fleet.config1",
|
|
}
|
|
|
|
// trigger a profile sync
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// get tokens again, timestamp and token should have changed
|
|
r, err := mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens := parseTokensResp(t, r)
|
|
currDeclToken := tokens.SyncTokens.DeclarationsToken
|
|
|
|
r, err = mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp := parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, currDeclToken, declsByToken)
|
|
|
|
// Now, retrieve the declaration configuration profiles
|
|
declarationPath := fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N0"])
|
|
r, err = mdmDevice.DeclarativeManagement(declarationPath)
|
|
require.NoError(t, err)
|
|
var gotParsed fleet.MDMAppleDDMDeclarationResponse
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.JSONEq(t, `{"ServiceType":"com.apple.bash0"}`, string(gotParsed.Payload))
|
|
|
|
declarationPath = fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N1"])
|
|
r, err = mdmDevice.DeclarativeManagement(declarationPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.JSONEq(t, `{"ServiceType":"com.apple.bash1"}`, string(gotParsed.Payload))
|
|
|
|
// Upload the same profiles again -- nothing should change
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, "dry_run", "true")
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent)
|
|
s.awaitTriggerProfileSchedule(t)
|
|
// Get tokens again
|
|
r, err = mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
currDeclToken = tokens.SyncTokens.DeclarationsToken
|
|
// Get declaration items -- the checksums should be the same as before
|
|
r, err = mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp = parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, currDeclToken, declsByToken)
|
|
|
|
// Change the secrets.
|
|
myBash = "my.new.bash"
|
|
req = fleet.CreateSecretVariablesRequest{
|
|
SecretVariables: []fleet.SecretVariable{
|
|
{
|
|
Name: "FLEET_SECRET_BASH",
|
|
Value: myBash, // changed
|
|
},
|
|
},
|
|
}
|
|
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, "dry_run", "true")
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent)
|
|
// The token of the declaration with the updated secret should have changed.
|
|
decl = getDeclaration(t, "N1")
|
|
assert.NotContains(t, string(decl.RawJSON), myBash)
|
|
assert.Contains(t, string(decl.RawJSON), "$"+fleet.ServerSecretPrefix+"BASH")
|
|
nameToIdentifier["N1"] = decl.Identifier
|
|
nameToUUID["N1"] = decl.DeclarationUUID
|
|
assert.NotEqual(t, n1Token, decl.Token)
|
|
// Update expected token
|
|
delete(declsByToken, n1Token)
|
|
declsByToken[decl.Token] = fleet.MDMAppleDeclaration{
|
|
Identifier: "com.fleet.config1",
|
|
}
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Get tokens again
|
|
r, err = mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
currDeclToken = tokens.SyncTokens.DeclarationsToken
|
|
// Only N1 should have changed
|
|
r, err = mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp = parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, currDeclToken, declsByToken)
|
|
|
|
// Now, retrieve the declaration configuration profiles
|
|
declarationPath = fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N0"])
|
|
r, err = mdmDevice.DeclarativeManagement(declarationPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.JSONEq(t, `{"ServiceType":"com.apple.bash0"}`, string(gotParsed.Payload))
|
|
|
|
declarationPath = fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N1"])
|
|
r, err = mdmDevice.DeclarativeManagement(declarationPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.JSONEq(t, `{"ServiceType":"my.new.bash"}`, string(gotParsed.Payload))
|
|
|
|
// Delete the profiles
|
|
s.Do("DELETE", "/api/latest/fleet/configuration_profiles/"+nameToUUID["N0"], nil, http.StatusOK)
|
|
|
|
// Ensure we can delete without any MDM turned on.
|
|
appCfg, err := s.ds.AppConfig(t.Context())
|
|
require.NoError(t, err)
|
|
appCfg.MDM.EnabledAndConfigured = false
|
|
require.NoError(t, s.ds.SaveAppConfig(t.Context(), appCfg))
|
|
s.Do("DELETE", "/api/latest/fleet/configuration_profiles/"+nameToUUID["N1"], nil, http.StatusOK)
|
|
|
|
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
|
|
require.Empty(t, resp.Profiles)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestAppleDDMReconciliation() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
addDeclaration := func(identifier string, teamID uint, labelNames []string) string {
|
|
fields := map[string][]string{
|
|
"labels": labelNames,
|
|
}
|
|
if teamID > 0 {
|
|
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
|
|
}
|
|
body, headers := generateNewProfileMultipartRequest(
|
|
t, identifier+".json", declarationForTest(identifier), s.token, fields,
|
|
)
|
|
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
|
|
var resp newMDMConfigProfileResponse
|
|
err := json.NewDecoder(res.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, resp.ProfileUUID)
|
|
require.Equal(t, "d", string(resp.ProfileUUID[0]))
|
|
return resp.ProfileUUID
|
|
}
|
|
|
|
deleteDeclaration := func(declUUID string) {
|
|
var deleteResp deleteMDMConfigProfileResponse
|
|
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", declUUID), nil, http.StatusOK, &deleteResp)
|
|
}
|
|
|
|
// create a team
|
|
teamName := t.Name() + "team1"
|
|
team := &fleet.Team{
|
|
Name: teamName,
|
|
}
|
|
var createTeamResp teamResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
|
|
require.NotZero(t, createTeamResp.Team.ID)
|
|
team = createTeamResp.Team
|
|
|
|
checkNoCommands := func(d *mdmtest.TestAppleMDMClient) {
|
|
cmd, err := d.Idle()
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
}
|
|
|
|
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
|
|
cmd, err := d.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
|
|
cmd, err = d.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
_, err = d.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// create a windows host
|
|
_, err := s.ds.NewHost(context.Background(), &fleet.Host{
|
|
ID: 1,
|
|
OsqueryHostID: ptr.String("non-macos-host"),
|
|
NodeKey: ptr.String("non-macos-host"),
|
|
UUID: uuid.New().String(),
|
|
Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
|
|
Platform: "windows",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// create a windows host that's enrolled in MDM
|
|
_, _ = createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
// create a linux host
|
|
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
|
|
ID: 2,
|
|
OsqueryHostID: ptr.String("linux-host"),
|
|
NodeKey: ptr.String("linux-host"),
|
|
UUID: uuid.New().String(),
|
|
Hostname: fmt.Sprintf("%sfoo.local.linux", t.Name()),
|
|
Platform: "linux",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// create a host that's not enrolled into MDM
|
|
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
|
|
ID: 2,
|
|
OsqueryHostID: ptr.String("not-mdm-enrolled"),
|
|
NodeKey: ptr.String("not-mdm-enrolled"),
|
|
UUID: uuid.New().String(),
|
|
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// create a host and then enroll in MDM.
|
|
mdmHost, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
// Create and then immediately delete a declaration
|
|
delUUID := addDeclaration("TestImmediateDelete", 0, nil)
|
|
deleteDeclaration(delUUID)
|
|
|
|
// trigger the reconciler, no error
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
// declarativeManagement command is not sent.
|
|
checkNoCommands(device)
|
|
|
|
// add global declarations
|
|
d1UUID := addDeclaration("I1", 0, nil)
|
|
addDeclaration("I2", 0, nil)
|
|
|
|
// reconcile again, this time new declarations were added
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
// TODO: check command is pending
|
|
|
|
// declarativeManagement command is sent
|
|
checkDDMSync(device)
|
|
|
|
// reconcile again, commands for the uploaded declarations are already sent
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// no new commands are sent
|
|
checkNoCommands(device)
|
|
|
|
// delete a declaration
|
|
deleteDeclaration(d1UUID)
|
|
// reconcile again
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// a DDM sync is triggered
|
|
checkDDMSync(device)
|
|
|
|
// add a new host
|
|
_, deviceTwo := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
// reconcile again
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// DDM sync is triggered only for the new host
|
|
checkNoCommands(device)
|
|
checkDDMSync(deviceTwo)
|
|
|
|
// add device to the team
|
|
s.Do("POST", "/api/v1/fleet/hosts/transfer",
|
|
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{mdmHost.ID}}, http.StatusOK)
|
|
|
|
// reconcile
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
// DDM sync is triggered only for the transferred host
|
|
// because the team doesn't have any declarations
|
|
checkDDMSync(device)
|
|
checkNoCommands(deviceTwo)
|
|
|
|
// reconcile
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// nobody receives commands this time
|
|
checkNoCommands(device)
|
|
checkNoCommands(deviceTwo)
|
|
|
|
// add declarations to the team
|
|
addDeclaration("I1", team.ID, nil)
|
|
addDeclaration("I2", team.ID, nil)
|
|
|
|
// reconcile
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// DDM sync is triggered for the host in the team
|
|
checkDDMSync(device)
|
|
checkNoCommands(deviceTwo)
|
|
|
|
// add a new host, this one belongs to the team
|
|
mdmHostThree, deviceThree := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
s.Do("POST", "/api/v1/fleet/hosts/transfer",
|
|
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{mdmHostThree.ID}}, http.StatusOK)
|
|
|
|
// reconcile
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// DDM sync is triggered only for the new host
|
|
checkNoCommands(device)
|
|
checkNoCommands(deviceTwo)
|
|
checkDDMSync(deviceThree)
|
|
|
|
// no new commands after another reconciliation
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
checkNoCommands(device)
|
|
checkNoCommands(deviceTwo)
|
|
checkNoCommands(deviceThree)
|
|
|
|
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: t.Name(), Query: "select 1;"})
|
|
require.NoError(t, err)
|
|
// update label with host membership
|
|
mysqltest.ExecAdhocSQL(
|
|
t, s.ds, func(db sqlx.ExtContext) error {
|
|
_, err := db.ExecContext(
|
|
context.Background(),
|
|
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
|
|
mdmHostThree.ID,
|
|
label.ID,
|
|
)
|
|
return err
|
|
},
|
|
)
|
|
|
|
// add a new label + label declaration
|
|
addDeclaration("I3", team.ID, []string{label.Name})
|
|
|
|
// reconcile
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
// DDM sync is triggered only for the host with the label
|
|
checkNoCommands(device)
|
|
checkNoCommands(deviceTwo)
|
|
checkDDMSync(deviceThree)
|
|
}
|
|
|
|
// TestAppleDDMAssetReconciliation asserts that editing a DDM asset referenced by
|
|
// a declaration re-syncs the host even when the declaration's own content is
|
|
// unchanged, and that the per-host declarations token changes accordingly. This
|
|
// is the assets_updated_at path mirroring variables_updated_at.
|
|
func (s *integrationMDMTestSuite) TestAppleDDMAssetReconciliation() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
checkNoCommands := func(d *mdmtest.TestAppleMDMClient) {
|
|
cmd, err := d.Idle()
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
}
|
|
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
|
|
cmd, err := d.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
|
|
cmd, err = d.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
}
|
|
|
|
// Read the current declarations token for the device (System channel).
|
|
currentToken := func(d *mdmtest.TestAppleMDMClient) string {
|
|
r, err := d.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
return parseTokensResp(t, r).SyncTokens.DeclarationsToken
|
|
}
|
|
|
|
// Read the manifest's Assets entry (identifier -> ServerToken) for the device.
|
|
assetServerToken := func(d *mdmtest.TestAppleMDMClient, identifier string) string {
|
|
r, err := d.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
items := parseDeclarationItemsResp(t, r)
|
|
for _, a := range items.Declarations.Assets {
|
|
if a.Identifier == identifier {
|
|
return a.ServerToken
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Enroll a macOS host.
|
|
_, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
// Create a DDM asset (global team) that a declaration will reference.
|
|
const assetIdentifier = "com.fleet.asset.reconcile"
|
|
_, err := s.ds.CreateAppleDDMAsset(ctx, "reconcile-asset", assetIdentifier, []byte(`{"Type":"com.apple.asset.data","Identifier":"com.fleet.asset.reconcile","Payload":{"Reference":{"DataURL":"https://example.com/a"}}}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
// Upload a declaration that references the asset. This links the reference via
|
|
// handleDeclarationAssetReferences.
|
|
declIdentifier := "com.fleet.decl.withasset"
|
|
body, headers := generateNewProfileMultipartRequest(
|
|
t, declIdentifier+".json", declarationForTestWithAssetReference(declIdentifier, assetIdentifier), s.token, nil,
|
|
)
|
|
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
|
|
var newProfResp newMDMConfigProfileResponse
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&newProfResp))
|
|
require.NotEmpty(t, newProfResp.ProfileUUID)
|
|
|
|
// First reconcile installs the declaration and stamps assets_updated_at.
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
checkDDMSync(device)
|
|
|
|
// Second reconcile is a no-op: nothing changed.
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
checkNoCommands(device)
|
|
|
|
tokenBefore := currentToken(device)
|
|
require.NotEmpty(t, tokenBefore)
|
|
|
|
// The manifest advertises the asset with a hex-encoded ServerToken, and the
|
|
// served asset declaration reports the same ServerToken.
|
|
manifestTokBefore := assetServerToken(device, assetIdentifier)
|
|
require.NotEmpty(t, manifestTokBefore)
|
|
r, err := device.DeclarativeManagement(fmt.Sprintf("declaration/asset/%s", assetIdentifier))
|
|
require.NoError(t, err)
|
|
var servedAsset map[string]any
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&servedAsset))
|
|
require.Equal(t, manifestTokBefore, servedAsset["ServerToken"])
|
|
|
|
// Simulate an asset edit: change its content and bump uploaded_at (as a future
|
|
// GitOps/asset-edit path would). The token column is generated from raw_json,
|
|
// so this also changes the asset's own token.
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
UPDATE mdm_apple_declaration_assets
|
|
SET raw_json = ?, uploaded_at = DATE_ADD(uploaded_at, INTERVAL 1 HOUR)
|
|
WHERE identifier = ? AND team_id = 0`,
|
|
`{"Type":"com.apple.asset.data","Identifier":"com.fleet.asset.reconcile","Payload":{"Reference":{"DataURL":"https://example.com/CHANGED"}}}`,
|
|
assetIdentifier)
|
|
return err
|
|
})
|
|
|
|
// Reconcile: even though the declaration itself is unchanged, the referenced
|
|
// asset moved forward, so the host must be poked.
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
checkDDMSync(device)
|
|
|
|
// The declarations token changed because assets_updated_at advanced.
|
|
tokenAfter := currentToken(device)
|
|
require.NotEmpty(t, tokenAfter)
|
|
require.NotEqual(t, tokenBefore, tokenAfter, "declarations token must change on an asset-only update")
|
|
|
|
// The asset's advertised ServerToken changed too, and the served declaration matches.
|
|
manifestTokAfter := assetServerToken(device, assetIdentifier)
|
|
require.NotEmpty(t, manifestTokAfter)
|
|
require.NotEqual(t, manifestTokBefore, manifestTokAfter)
|
|
r, err = device.DeclarativeManagement(fmt.Sprintf("declaration/asset/%s", assetIdentifier))
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&servedAsset))
|
|
require.Equal(t, manifestTokAfter, servedAsset["ServerToken"])
|
|
|
|
// A final reconcile is idempotent again.
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
checkNoCommands(device)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestAppleDDMStatusReport() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
assertHostDeclarations := func(hostUUID string, wantDecls []*fleet.MDMAppleHostDeclaration) {
|
|
var gotDecls []*fleet.MDMAppleHostDeclaration
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(context.Background(), q, &gotDecls, `SELECT declaration_identifier, status, operation_type FROM host_mdm_apple_declarations WHERE host_uuid = ?`, hostUUID)
|
|
})
|
|
require.ElementsMatch(t, wantDecls, gotDecls)
|
|
}
|
|
|
|
// create a host and then enroll in MDM.
|
|
mdmHost, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
declarations := []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1.json", Contents: declarationForTest("I1")},
|
|
{Name: "N2.json", Contents: declarationForTest("I2")},
|
|
{Name: "Unknown.json", Contents: declarationForTestWithType("I3", "com.apple.configuration.")},
|
|
}
|
|
// add global declarations
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
|
|
|
|
// reconcile profiles
|
|
err := ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
// declarations are ("install", "pending") after the cron run
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I2", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I3", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// host gets a DDM sync call
|
|
cmd, err := device.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
|
|
_, err = device.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
|
|
r, err := device.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
var items fleet.MDMAppleDDMDeclarationItemsResponse
|
|
require.NoError(t, json.Unmarshal(body, &items))
|
|
|
|
var i1ServerToken, i2ServerToken, i3ServerToken string
|
|
for _, d := range items.Declarations.Configurations {
|
|
switch d.Identifier {
|
|
case "I1":
|
|
i1ServerToken = d.ServerToken
|
|
case "I2":
|
|
i2ServerToken = d.ServerToken
|
|
case "I3":
|
|
i3ServerToken = d.ServerToken
|
|
}
|
|
}
|
|
|
|
// declarations are ("install", "verifying") after the ack
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I2", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I3", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// host sends a partial DDM report
|
|
report := fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
|
|
}
|
|
_, err = device.DeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I2", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I3", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// host sends a report with a wrong (could be old) server token for I2, nothing changes
|
|
report = fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I2", ServerToken: "foo"},
|
|
}
|
|
_, err = device.DeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I2", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I3", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// host sends a full report, declaration I2 is invalid
|
|
report = fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
|
|
{Active: false, Valid: fleet.MDMAppleDeclarationInvalid, Identifier: "I2", ServerToken: i2ServerToken},
|
|
{Active: false, Valid: fleet.MDMAppleDeclarationUnknown, Identifier: "I3", ServerToken: i3ServerToken, Reasons: []fleet.MDMAppleDDMStatusErrorReason{
|
|
{
|
|
Code: "Error.UnknownDeclarationType",
|
|
},
|
|
}},
|
|
}
|
|
_, err = device.DeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I2", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
|
|
{Identifier: "I3", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// do a batch request, this time I2 is deleted
|
|
declarations = []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1.json", Contents: declarationForTest("I1")},
|
|
}
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
|
|
|
|
// reconcile profiles
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// host sends a report, declaration I2 is removed from the hosts_* table
|
|
report = fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
|
|
}
|
|
_, err = device.DeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
|
|
// host sends a report, declaration I1 is failing after a while
|
|
report = fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: false, Valid: fleet.MDMAppleDeclarationInvalid, Identifier: "I1", ServerToken: i1ServerToken},
|
|
}
|
|
_, err = device.DeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "I1", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
}
|
|
|
|
// TestAppleUserScopedDDMEndToEnd drives the full user-channel DDM flow through
|
|
// the simulated MDM client: a device enrolls, then a user channel enrolls, a
|
|
// user-scoped and a device-scoped declaration are uploaded, and the client
|
|
// exercises each channel independently (idle → ack → declaration-items →
|
|
// declaration content → status report). It verifies the two channels stay
|
|
// isolated, PayloadScope is stripped from the delivered content, and a
|
|
// user-channel status report only transitions the user-scoped declaration.
|
|
func (s *integrationMDMTestSuite) TestAppleUserScopedDDMEndToEnd() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
assertHostDeclarations := func(hostUUID string, want []*fleet.MDMAppleHostDeclaration) {
|
|
var got []*fleet.MDMAppleHostDeclaration
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &got,
|
|
`SELECT declaration_identifier, status, operation_type, scope FROM host_mdm_apple_declarations WHERE host_uuid = ?`, hostUUID)
|
|
})
|
|
require.ElementsMatch(t, want, got)
|
|
}
|
|
|
|
// Enroll a device, then add a user-channel enrollment for the same device.
|
|
mdmHost, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
require.NoError(t, device.UserEnroll())
|
|
|
|
// One device-scoped and one user-scoped declaration.
|
|
declarations := []fleet.MDMProfileBatchPayload{
|
|
{Name: "Device.json", Contents: declarationForTest("com.fleet.device")},
|
|
{Name: "User.json", Contents: declarationForTestWithScope("com.fleet.user", fleet.PayloadScopeUser)},
|
|
}
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
|
|
|
|
// After reconcile each declaration is pending on its own channel.
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "com.fleet.device", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeSystem},
|
|
{Identifier: "com.fleet.user", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeUser},
|
|
})
|
|
|
|
// The device channel gets its own DeclarativeManagement command...
|
|
deviceCmd, err := device.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, deviceCmd)
|
|
require.Equal(t, "DeclarativeManagement", deviceCmd.Command.RequestType)
|
|
_, err = device.Acknowledge(deviceCmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
|
|
// ...and the user channel gets its own, independent one.
|
|
userCmd, err := device.UserIdle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, userCmd)
|
|
require.Equal(t, "DeclarativeManagement", userCmd.Command.RequestType)
|
|
require.NotEqual(t, deviceCmd.CommandUUID, userCmd.CommandUUID)
|
|
_, err = device.UserAcknowledge(userCmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
|
|
// After the acks, each channel's declaration transitions to verifying.
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "com.fleet.device", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeSystem},
|
|
{Identifier: "com.fleet.user", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeUser},
|
|
})
|
|
|
|
configsFor := func(r *http.Response) []fleet.MDMAppleDDMManifest {
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
var items fleet.MDMAppleDDMDeclarationItemsResponse
|
|
require.NoError(t, json.Unmarshal(body, &items))
|
|
return items.Declarations.Configurations
|
|
}
|
|
|
|
// The user channel's declaration-items contains ONLY the user-scoped declaration.
|
|
ur, err := device.UserDeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
userConfigs := configsFor(ur)
|
|
require.Len(t, userConfigs, 1)
|
|
require.Equal(t, "com.fleet.user", userConfigs[0].Identifier)
|
|
userServerToken := userConfigs[0].ServerToken
|
|
|
|
// The device channel's declaration-items contains ONLY the device-scoped declaration.
|
|
dr, err := device.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
deviceConfigs := configsFor(dr)
|
|
require.Len(t, deviceConfigs, 1)
|
|
require.Equal(t, "com.fleet.device", deviceConfigs[0].Identifier)
|
|
|
|
// The user-scoped declaration content is served on the user channel with the
|
|
// Fleet-only PayloadScope key stripped.
|
|
cr, err := device.UserDeclarativeManagement("declaration/configuration/com.fleet.user")
|
|
require.NoError(t, err)
|
|
cbody, err := io.ReadAll(cr.Body)
|
|
require.NoError(t, err)
|
|
var served map[string]any
|
|
require.NoError(t, json.Unmarshal(cbody, &served))
|
|
require.Equal(t, "com.fleet.user", served["Identifier"])
|
|
require.NotContains(t, served, "PayloadScope", "PayloadScope must be stripped from the declaration served to the device")
|
|
|
|
// The user channel reports the declaration as valid+active.
|
|
report := fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "com.fleet.user", ServerToken: userServerToken},
|
|
}
|
|
_, err = device.UserDeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
|
|
// The user-scoped declaration is verified; the device-scoped one is untouched
|
|
// by the user-channel report (still verifying — its own status report hasn't
|
|
// arrived).
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "com.fleet.device", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeSystem},
|
|
{Identifier: "com.fleet.user", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeUser},
|
|
})
|
|
|
|
// --- Delete the user-scoped declaration only (device-scoped remains) ---
|
|
// Re-apply the batch without the user-scoped declaration.
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "Device.json", Contents: declarationForTest("com.fleet.device")},
|
|
}}, http.StatusNoContent)
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
|
|
// The user-scoped declaration becomes a pending remove on the user channel;
|
|
// the device-scoped one is untouched.
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "com.fleet.device", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeSystem},
|
|
{Identifier: "com.fleet.user", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeRemove, Scope: fleet.PayloadScopeUser},
|
|
})
|
|
|
|
// Only the user channel was poked: the device channel has no new command.
|
|
deviceCmd, err = device.Idle()
|
|
require.NoError(t, err)
|
|
require.Nil(t, deviceCmd, "device channel must not be poked by a user-scoped removal")
|
|
|
|
// The user channel syncs the removal: its declaration-items is now empty.
|
|
userCmd, err = device.UserIdle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, userCmd)
|
|
require.Equal(t, "DeclarativeManagement", userCmd.Command.RequestType)
|
|
_, err = device.UserAcknowledge(userCmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
ur, err = device.UserDeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
require.Empty(t, configsFor(ur))
|
|
|
|
// The user channel reports the (now empty) set, clearing the pending remove.
|
|
// The device-scoped declaration is still present and untouched.
|
|
_, err = device.UserDeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "com.fleet.device", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall, Scope: fleet.PayloadScopeSystem},
|
|
})
|
|
|
|
// --- Delete the device-scoped declaration too (nothing remains) ---
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{}}, http.StatusNoContent)
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
|
|
// The device-scoped declaration becomes a pending remove on the device channel.
|
|
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
|
|
{Identifier: "com.fleet.device", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeRemove, Scope: fleet.PayloadScopeSystem},
|
|
})
|
|
|
|
// This time only the device channel is poked; the user channel has no command.
|
|
userCmd, err = device.UserIdle()
|
|
require.NoError(t, err)
|
|
require.Nil(t, userCmd, "user channel must not be poked by a device-scoped removal")
|
|
|
|
deviceCmd, err = device.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, deviceCmd)
|
|
require.Equal(t, "DeclarativeManagement", deviceCmd.Command.RequestType)
|
|
_, err = device.Acknowledge(deviceCmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
dr, err = device.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
require.Empty(t, configsFor(dr))
|
|
|
|
// The device channel reports the empty set, clearing the last pending remove.
|
|
_, err = device.DeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
|
|
require.NoError(t, err)
|
|
assertHostDeclarations(mdmHost.UUID, nil)
|
|
}
|
|
|
|
// TestAppleDDMResyncPokesWithoutDeltas is a regression test: a host that
|
|
// requested a resync (the resync flag on host_mdm_apple_declarations, set by the
|
|
// remove+install-same-token cleanup) must get a DeclarativeManagement command on
|
|
// the next reconcile even when there are no declaration deltas that tick.
|
|
// Previously the reconciler early-returned on empty deltas, stranding the resync
|
|
// flag set forever.
|
|
func (s *integrationMDMTestSuite) TestAppleDDMResyncPokesWithoutDeltas() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
mdmHost, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
// One declaration, reconciled so it's installed and no longer changing.
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1.json", Contents: declarationForTest("com.fleet.resync")},
|
|
}}, http.StatusNoContent)
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
|
|
// Drain the channel so it is idle with no pending deltas.
|
|
for {
|
|
cmd, err := device.Idle()
|
|
require.NoError(t, err)
|
|
if cmd == nil {
|
|
break
|
|
}
|
|
_, err = device.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Flag the host declaration for resync, as cleanUpDuplicateRemoveInstall does.
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET resync = 1 WHERE host_uuid = ?`, mdmHost.UUID)
|
|
return err
|
|
})
|
|
|
|
// A reconcile with no declaration deltas must still poke the host because of
|
|
// the resync flag.
|
|
require.NoError(t, ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger))
|
|
|
|
cmd, err := device.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd, "resync-only host must be poked even when there are no declaration deltas")
|
|
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
|
|
|
|
// The resync flag was cleared.
|
|
var resync bool
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &resync, `SELECT resync FROM host_mdm_apple_declarations WHERE host_uuid = ?`, mdmHost.UUID)
|
|
})
|
|
require.False(t, resync)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestDDMUnsupportedDevice() {
|
|
t := s.T()
|
|
s.setSkipWorkerJobs(t)
|
|
ctx := context.Background()
|
|
fleetHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
getProfiles := func(h *fleet.Host) map[string]*fleet.HostMDMAppleProfile {
|
|
profs, err := s.ds.GetHostMDMAppleProfiles(ctx, h.UUID)
|
|
require.NoError(t, err)
|
|
out := make(map[string]*fleet.HostMDMAppleProfile, len(profs))
|
|
for _, p := range profs {
|
|
p := p
|
|
out[p.Identifier] = &p
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
declarations := []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1.json", Contents: declarationForTest("I1")},
|
|
{Name: "N2.json", Contents: declarationForTest("I2")},
|
|
}
|
|
// add global declarations
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
|
|
|
|
// reconcile declarations
|
|
err := ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
// declaration is pending
|
|
profs := getProfiles(fleetHost)
|
|
require.Equal(t, &fleet.MDMDeliveryPending, profs["I1"].Status)
|
|
require.Equal(t, &fleet.MDMDeliveryPending, profs["I2"].Status)
|
|
|
|
cmd, err := mdmDevice.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
|
|
|
|
// simulate an error returned by devices that don't support DDM
|
|
errChain := []mdm.ErrorChain{
|
|
{
|
|
ErrorCode: 4,
|
|
ErrorDomain: "RMErrorDomain",
|
|
LocalizedDescription: "Feature Disabled: DeclarativeManagement is disabled.",
|
|
},
|
|
}
|
|
cmd, err = mdmDevice.Err(cmd.CommandUUID, errChain)
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
|
|
// profiles are failed
|
|
profs = getProfiles(fleetHost)
|
|
require.Equal(t, &fleet.MDMDeliveryFailed, profs["I1"].Status)
|
|
require.Contains(t, profs["I1"].Detail, "Feature Disabled")
|
|
require.Equal(t, &fleet.MDMDeliveryFailed, profs["I2"].Status)
|
|
require.Contains(t, profs["I2"].Detail, "Feature Disabled")
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestDDMNoDeclarationsLeft() {
|
|
t := s.T()
|
|
_, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
res, err := mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
var tok fleet.MDMAppleDDMTokensResponse
|
|
err = json.NewDecoder(res.Body).Decode(&tok)
|
|
require.NoError(t, err)
|
|
require.Empty(t, tok.SyncTokens.DeclarationsToken)
|
|
require.NotEmpty(t, tok.SyncTokens.Timestamp)
|
|
|
|
res, err = mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
var items fleet.MDMAppleDDMDeclarationItemsResponse
|
|
err = json.NewDecoder(res.Body).Decode(&items)
|
|
require.NoError(t, err)
|
|
require.Empty(t, items.DeclarationsToken)
|
|
require.Empty(t, items.Declarations.Activations)
|
|
require.Empty(t, items.Declarations.Configurations)
|
|
require.Empty(t, items.Declarations.Assets)
|
|
require.Empty(t, items.Declarations.Management)
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestDDMTransactionRecording() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
|
|
type record struct {
|
|
EnrollmentID string `db:"enrollment_id"`
|
|
MessageType string `db:"message_type"`
|
|
RawJSON *json.RawMessage `db:"raw_json"`
|
|
}
|
|
verifyTransactionRecord := func(want record) {
|
|
var got record
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(
|
|
ctx, q, &got,
|
|
`SELECT
|
|
enrollment_id, message_type, raw_json
|
|
FROM mdm_apple_declarative_requests
|
|
ORDER BY id DESC
|
|
LIMIT 1`,
|
|
)
|
|
})
|
|
if got.RawJSON != nil {
|
|
fmt.Println(string(*got.RawJSON))
|
|
}
|
|
require.Equal(t, want, got)
|
|
}
|
|
|
|
declarations := []fleet.MDMProfileBatchPayload{
|
|
{Name: "N1.json", Contents: declarationForTest("I1")},
|
|
{Name: "N2.json", Contents: declarationForTest("I2")},
|
|
}
|
|
// add global declarations
|
|
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
|
|
|
|
// reconcile declarations
|
|
err := ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
_, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
_, err = mdmDevice.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
verifyTransactionRecord(record{
|
|
MessageType: "tokens",
|
|
EnrollmentID: mdmDevice.UUID,
|
|
RawJSON: nil,
|
|
})
|
|
|
|
res, err := mdmDevice.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
verifyTransactionRecord(record{
|
|
MessageType: "declaration-items",
|
|
EnrollmentID: mdmDevice.UUID,
|
|
RawJSON: nil,
|
|
})
|
|
|
|
var items fleet.MDMAppleDDMDeclarationItemsResponse
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&items))
|
|
var i1ServerToken string
|
|
for _, d := range items.Declarations.Configurations {
|
|
if d.Identifier == "I1" {
|
|
i1ServerToken = d.ServerToken
|
|
}
|
|
}
|
|
|
|
// a second device requests tokens
|
|
_, mdmDeviceTwo := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
err = ReconcileAppleDeclarationsBatched(ctx, s.ds, s.mdmCommander, s.logger)
|
|
require.NoError(t, err)
|
|
|
|
_, err = mdmDeviceTwo.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
verifyTransactionRecord(record{
|
|
MessageType: "tokens",
|
|
EnrollmentID: mdmDeviceTwo.UUID,
|
|
RawJSON: nil,
|
|
})
|
|
|
|
_, err = mdmDevice.DeclarativeManagement("declaration/configuration/I1")
|
|
require.NoError(t, err)
|
|
verifyTransactionRecord(record{
|
|
MessageType: "declaration/configuration/I1",
|
|
EnrollmentID: mdmDevice.UUID,
|
|
RawJSON: nil,
|
|
})
|
|
|
|
report := fleet.MDMAppleDDMStatusReport{}
|
|
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
|
|
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
|
|
}
|
|
_, err = mdmDevice.DeclarativeManagement("status", report)
|
|
require.NoError(t, err)
|
|
verifyTransactionRecord(record{
|
|
MessageType: "status",
|
|
EnrollmentID: mdmDevice.UUID,
|
|
RawJSON: ptr.RawMessage(
|
|
json.RawMessage(
|
|
fmt.Sprintf(
|
|
`{"StatusItems":{"management":{"declarations":{"activations":null,"configurations":[{"active":true,"identifier":"I1","valid":"valid","server-token":"%s"}],"assets":null,"management":null}}},"Errors":null}`,
|
|
i1ServerToken,
|
|
),
|
|
),
|
|
),
|
|
})
|
|
}
|
|
|
|
func (s *integrationMDMTestSuite) TestAppleDDMFleetVariables() {
|
|
t := s.T()
|
|
ctx := t.Context()
|
|
|
|
// === Setup ===
|
|
|
|
// Create two MDM-enrolled hosts
|
|
host1, mdmDevice1 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
_, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
|
|
// Set host1's serial to a value with characters that need JSON escaping,
|
|
// to verify that variable substitution produces valid JSON.
|
|
host1.HardwareSerial = `SER"IAL\123`
|
|
err := s.ds.UpdateHost(ctx, host1)
|
|
require.NoError(t, err)
|
|
|
|
// Create a team and transfer host1 into it; host2 stays global (control)
|
|
team := &fleet.Team{Name: t.Name() + "team1"}
|
|
var createTeamResp teamResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
|
|
require.NotZero(t, createTeamResp.Team.ID)
|
|
team = createTeamResp.Team
|
|
|
|
s.Do("POST", "/api/v1/fleet/hosts/transfer",
|
|
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{host1.ID}}, http.StatusOK)
|
|
|
|
// Helper: read declaration from DB by name
|
|
getDeclaration := func(t *testing.T, name string) fleet.MDMAppleDeclaration {
|
|
stmt := `
|
|
SELECT
|
|
declaration_uuid,
|
|
team_id,
|
|
identifier,
|
|
name,
|
|
raw_json,
|
|
HEX(token) as token,
|
|
created_at,
|
|
uploaded_at
|
|
FROM mdm_apple_declarations
|
|
WHERE name = ?`
|
|
|
|
var decl fleet.MDMAppleDeclaration
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &decl, stmt, name)
|
|
})
|
|
return decl
|
|
}
|
|
|
|
// Helper: read variables_updated_at for a host/declaration pair
|
|
getHostDeclVarsUpdatedAt := func(t *testing.T, hostUUID, declUUID string) *time.Time {
|
|
var result []time.Time
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &result,
|
|
`SELECT variables_updated_at FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ? AND variables_updated_at IS NOT NULL`,
|
|
hostUUID, declUUID)
|
|
})
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return &result[0]
|
|
}
|
|
|
|
checkNoCommands := func(d *mdmtest.TestAppleMDMClient) {
|
|
cmd, err := d.Idle()
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
}
|
|
|
|
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
|
|
cmd, err := d.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
|
|
cmd, err = d.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, cmd)
|
|
_, err = d.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string,
|
|
expectedDeclsByToken map[string]fleet.MDMAppleDeclaration,
|
|
) {
|
|
require.Equal(t, expectedDeclTok, r.DeclarationsToken)
|
|
require.NotEmpty(t, r.Declarations.Activations)
|
|
require.Empty(t, r.Declarations.Assets)
|
|
require.Empty(t, r.Declarations.Management)
|
|
require.Len(t, r.Declarations.Configurations, len(expectedDeclsByToken))
|
|
for _, m := range r.Declarations.Configurations {
|
|
d, ok := expectedDeclsByToken[m.ServerToken]
|
|
require.True(t, ok, "server token %x not found for %s", m.ServerToken, m.Identifier)
|
|
require.Equal(t, d.Identifier, m.Identifier)
|
|
}
|
|
}
|
|
|
|
teamIDStr := fmt.Sprintf("%d", team.ID)
|
|
|
|
// Declaration payloads
|
|
declWithUUID := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "$FLEET_VAR_HOST_UUID"},
|
|
"Identifier": "com.fleet.var.uuid"
|
|
}`)
|
|
declWithSerial := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "$FLEET_VAR_HOST_HARDWARE_SERIAL"},
|
|
"Identifier": "com.fleet.var.serial"
|
|
}`)
|
|
declPlain := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "static-value"},
|
|
"Identifier": "com.fleet.plain"
|
|
}`)
|
|
|
|
// === Failing upload (unsupported variable) ===
|
|
|
|
badDecl := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "$FLEET_VAR_NDES_SCEP_CHALLENGE"},
|
|
"Identifier": "com.fleet.bad"
|
|
}`)
|
|
badReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "BadDecl.json", Contents: badDecl},
|
|
}}
|
|
badRes := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", badReq, http.StatusBadRequest,
|
|
"team_id", teamIDStr)
|
|
errMsg := extractServerErrorText(badRes.Body)
|
|
require.Contains(t, errMsg, "$FLEET_VAR_NDES_SCEP_CHALLENGE is not supported in DDM")
|
|
|
|
// === Upload declarations with and without variables ===
|
|
|
|
profilesReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "VarUUID.json", Contents: declWithUUID},
|
|
{Name: "VarSerial.json", Contents: declWithSerial},
|
|
{Name: "Plain.json", Contents: declPlain},
|
|
}}
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
|
|
// Verify raw JSON stored as-is (variables not expanded in storage)
|
|
dbDeclUUID := getDeclaration(t, "VarUUID.json")
|
|
assert.Contains(t, string(dbDeclUUID.RawJSON), "$FLEET_VAR_HOST_UUID")
|
|
dbDeclSerial := getDeclaration(t, "VarSerial.json")
|
|
assert.Contains(t, string(dbDeclSerial.RawJSON), "$FLEET_VAR_HOST_HARDWARE_SERIAL")
|
|
dbDeclPlain := getDeclaration(t, "Plain.json")
|
|
assert.Contains(t, string(dbDeclPlain.RawJSON), "static-value")
|
|
|
|
// === First sync — verify variable substitution ===
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
checkDDMSync(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
// Host1 fetches tokens
|
|
r, err := mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens := parseTokensResp(t, r)
|
|
lastSyncDeclToken := tokens.SyncTokens.DeclarationsToken
|
|
require.NotEmpty(t, lastSyncDeclToken)
|
|
|
|
// Fetch individual declarations and verify substitution
|
|
var gotParsed fleet.MDMAppleDDMDeclarationResponse
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid")
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.Contains(t, string(gotParsed.Payload), host1.UUID)
|
|
assert.NotContains(t, string(gotParsed.Payload), "$FLEET_VAR")
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.serial")
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.NotContains(t, string(gotParsed.Payload), "$FLEET_VAR")
|
|
// Verify the serial (which contains " and \) is properly JSON-escaped:
|
|
// the payload must be valid JSON and unmarshal to the original value.
|
|
var serialPayload struct{ Echo string }
|
|
require.NoError(t, json.Unmarshal(gotParsed.Payload, &serialPayload))
|
|
assert.Equal(t, host1.HardwareSerial, serialPayload.Echo)
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.plain")
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.Contains(t, string(gotParsed.Payload), "static-value")
|
|
|
|
// Verify variables_updated_at: set for variable decls, nil for plain
|
|
varsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedUUID)
|
|
varsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerial)
|
|
varsUpdatedPlain := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclPlain.DeclarationUUID)
|
|
require.Nil(t, varsUpdatedPlain)
|
|
|
|
// Build expected declaration-items map with effective tokens (incorporating variables_updated_at)
|
|
declsByToken := map[string]fleet.MDMAppleDeclaration{
|
|
fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID, nil): {Identifier: "com.fleet.var.uuid"},
|
|
fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial, nil): {Identifier: "com.fleet.var.serial"},
|
|
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
|
|
}
|
|
|
|
// Host1 fetches declaration items
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp := parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
|
|
|
|
// === No resend when unrelated declaration added ===
|
|
|
|
newDecl := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "new-stuff"},
|
|
"Identifier": "com.fleet.new"
|
|
}`)
|
|
profilesReqWithNew := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "VarUUID.json", Contents: declWithUUID},
|
|
{Name: "VarSerial.json", Contents: declWithSerial},
|
|
{Name: "Plain.json", Contents: declPlain},
|
|
{Name: "NewDecl.json", Contents: newDecl},
|
|
}}
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqWithNew, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
|
|
dbNewDecl := getDeclaration(t, "NewDecl.json")
|
|
assert.Contains(t, string(dbNewDecl.RawJSON), "new-stuff")
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Host1 gets DDM sync (declaration set changed), host2 nothing
|
|
checkDDMSync(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
|
|
require.NotEmpty(t, lastSyncDeclToken)
|
|
|
|
declsByToken = map[string]fleet.MDMAppleDeclaration{
|
|
fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID, nil): {Identifier: "com.fleet.var.uuid"},
|
|
fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial, nil): {Identifier: "com.fleet.var.serial"},
|
|
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
|
|
dbNewDecl.Token: {Identifier: "com.fleet.new"},
|
|
}
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp = parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
|
|
|
|
// variables_updated_at did NOT change for existing variable declarations
|
|
varsUpdatedUUIDAfterAdd := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedUUIDAfterAdd)
|
|
assert.Equal(t, *varsUpdatedUUID, *varsUpdatedUUIDAfterAdd)
|
|
varsUpdatedSerialAfterAdd := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerialAfterAdd)
|
|
assert.Equal(t, *varsUpdatedSerial, *varsUpdatedSerialAfterAdd)
|
|
|
|
// === No resend when unrelated declaration deleted ===
|
|
|
|
// new decl is not in profilesReq, so it will be deleted
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Host1 gets DDM sync (declaration set changed), host2 nothing
|
|
checkDDMSync(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
declsByToken = map[string]fleet.MDMAppleDeclaration{
|
|
fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID, nil): {Identifier: "com.fleet.var.uuid"},
|
|
fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial, nil): {Identifier: "com.fleet.var.serial"},
|
|
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
|
|
}
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
|
|
require.NotEmpty(t, lastSyncDeclToken)
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp = parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
|
|
|
|
// variables_updated_at still unchanged
|
|
varsUpdatedUUIDAfterDel := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedUUIDAfterDel)
|
|
assert.Equal(t, *varsUpdatedUUID, *varsUpdatedUUIDAfterDel)
|
|
varsUpdatedSerialAfterDel := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerialAfterDel)
|
|
assert.Equal(t, *varsUpdatedSerial, *varsUpdatedSerialAfterDel)
|
|
|
|
// === No resend on no-op GitOps batch upload ===
|
|
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// No commands for either host — nothing changed
|
|
checkNoCommands(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
// Token unchanged
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
assert.Equal(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
|
|
|
|
// === Resend when variable values change ===
|
|
|
|
// Simulate variable value change: set status = NULL on variable declarations.
|
|
// This is the same operation triggerResendProfilesUsingVariables performs.
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx,
|
|
`UPDATE host_mdm_apple_declarations SET status = NULL
|
|
WHERE host_uuid = ? AND declaration_uuid IN (?, ?)`,
|
|
host1.UUID, dbDeclUUID.DeclarationUUID, dbDeclSerial.DeclarationUUID,
|
|
)
|
|
return err
|
|
})
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Host1 gets DDM sync, host2 nothing
|
|
checkDDMSync(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
// variables_updated_at for variable declarations was updated (newer)
|
|
varsUpdatedUUIDAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedUUIDAfterChange)
|
|
assert.True(t, varsUpdatedUUIDAfterChange.After(*varsUpdatedUUID),
|
|
"variables_updated_at should be newer after variable change, got %v vs original %v", varsUpdatedUUIDAfterChange, varsUpdatedUUID)
|
|
varsUpdatedSerialAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerialAfterChange)
|
|
assert.True(t, varsUpdatedSerialAfterChange.After(*varsUpdatedSerial),
|
|
"variables_updated_at should be newer after variable change, got %v vs original %v", varsUpdatedSerialAfterChange, varsUpdatedSerial)
|
|
|
|
// Plain declaration's variables_updated_at is still nil
|
|
varsUpdatedPlainAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclPlain.DeclarationUUID)
|
|
require.Nil(t, varsUpdatedPlainAfterChange)
|
|
|
|
// Token changed
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
|
|
|
|
// Variables still substituted correctly
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid")
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.Contains(t, string(gotParsed.Payload), host1.UUID)
|
|
|
|
// === Variable change on one host does not resend to teammate ===
|
|
|
|
// Create a third host on the same team as host1
|
|
host3, mdmDevice3 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
s.Do("POST", "/api/v1/fleet/hosts/transfer",
|
|
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{host3.ID}}, http.StatusOK)
|
|
|
|
// Let host3 complete its initial DDM sync
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
checkDDMSync(mdmDevice3)
|
|
checkNoCommands(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
// Record host3's variables_updated_at after initial sync
|
|
host3InitVarsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, host3InitVarsUpdatedUUID)
|
|
host3InitVarsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, host3InitVarsUpdatedSerial)
|
|
|
|
// Verify stable state: no-op batch upload triggers no commands for anyone
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
s.awaitTriggerProfileSchedule(t)
|
|
checkNoCommands(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
checkNoCommands(mdmDevice3)
|
|
|
|
// Simulate variable change for host1 only
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx,
|
|
`UPDATE host_mdm_apple_declarations SET status = NULL
|
|
WHERE host_uuid = ? AND declaration_uuid IN (?, ?)`,
|
|
host1.UUID, dbDeclUUID.DeclarationUUID, dbDeclSerial.DeclarationUUID,
|
|
)
|
|
return err
|
|
})
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Only host1 gets DDM sync; host3 (same team) and host2 (global) do not
|
|
checkDDMSync(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
checkNoCommands(mdmDevice3)
|
|
|
|
// Verify host3's variables_updated_at was not changed by host1's resend
|
|
varsUpdatedUUIDHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedUUIDHost3)
|
|
assert.Equal(t, *host3InitVarsUpdatedUUID, *varsUpdatedUUIDHost3)
|
|
varsUpdatedSerialHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerialHost3)
|
|
assert.Equal(t, *host3InitVarsUpdatedSerial, *varsUpdatedSerialHost3)
|
|
|
|
// host3 fetches its own declarations — variables are correctly substituted
|
|
// with host3's own values
|
|
r, err = mdmDevice3.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid")
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.Contains(t, string(gotParsed.Payload), host3.UUID)
|
|
assert.NotContains(t, string(gotParsed.Payload), host1.UUID)
|
|
|
|
r, err = mdmDevice3.DeclarativeManagement("declaration/configuration/com.fleet.var.serial")
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
|
|
assert.Contains(t, string(gotParsed.Payload), host3.HardwareSerial)
|
|
assert.NotContains(t, string(gotParsed.Payload), host1.HardwareSerial)
|
|
|
|
// === Failed variable resolution (no IdP user for host) ===
|
|
|
|
declWithIdpUsername := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"},
|
|
"Identifier": "com.fleet.var.idpusername"
|
|
}`)
|
|
profilesReqWithIdp := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "VarUUID.json", Contents: declWithUUID},
|
|
{Name: "VarSerial.json", Contents: declWithSerial},
|
|
{Name: "Plain.json", Contents: declPlain},
|
|
{Name: "VarIdpUsername.json", Contents: declWithIdpUsername},
|
|
}}
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqWithIdp, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
|
|
dbDeclIdpUsername := getDeclaration(t, "VarIdpUsername.json")
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Host1 gets DDM sync (declaration set changed)
|
|
checkDDMSync(mdmDevice1)
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
|
|
require.NotEmpty(t, lastSyncDeclToken)
|
|
|
|
// Get current variables_updated_at for host1's declarations (may have changed since earlier captures)
|
|
latestVarsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
|
|
latestVarsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
|
|
// The IDP declaration is excluded from the manifest because its variable
|
|
// can't be resolved (no IdP user for this host), but it is still included
|
|
// in the DeclarationsToken computation so that the token matches the
|
|
// SQL-computed token from the tokens endpoint.
|
|
declsByToken = map[string]fleet.MDMAppleDeclaration{
|
|
fleet.EffectiveDDMToken(dbDeclUUID.Token, latestVarsUpdatedUUID, nil): {Identifier: "com.fleet.var.uuid"},
|
|
fleet.EffectiveDDMToken(dbDeclSerial.Token, latestVarsUpdatedSerial, nil): {Identifier: "com.fleet.var.serial"},
|
|
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
|
|
}
|
|
|
|
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
|
|
require.NoError(t, err)
|
|
itemsResp = parseDeclarationItemsResp(t, r)
|
|
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
|
|
|
|
// Verify the IDP declaration is marked as failed after the declaration-items
|
|
// fetch (handleDeclarationItems detected unresolvable variables and excluded
|
|
// the declaration from the manifest).
|
|
var hostDecl fleet.MDMAppleHostDeclaration
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &hostDecl,
|
|
`SELECT status, detail FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`,
|
|
host1.UUID, dbDeclIdpUsername.DeclarationUUID)
|
|
})
|
|
require.NotNil(t, hostDecl.Status)
|
|
assert.Equal(t, fleet.MDMDeliveryFailed, *hostDecl.Status)
|
|
assert.Contains(t, hostDecl.Detail, "There is no IdP username for this host")
|
|
assert.Contains(t, hostDecl.Detail, "$FLEET_VAR_HOST_END_USER_IDP_USERNAME")
|
|
|
|
// Host1 fetches the IdP username configuration — variable resolution
|
|
// fails again (fallback path). The server returns an empty 200.
|
|
_, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.idpusername")
|
|
require.NoError(t, err)
|
|
|
|
// === Updating variable declaration to non-variable clears variables_updated_at ===
|
|
|
|
// Drain host3's pending DDM sync from the IdP batch upload above
|
|
checkDDMSync(mdmDevice3)
|
|
|
|
// Capture current token for comparison
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
|
|
require.NotEmpty(t, lastSyncDeclToken)
|
|
|
|
// Verify variables_updated_at is non-nil for VarUUID and VarSerial on both team hosts
|
|
preVarsUpdatedUUIDHost1 := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, preVarsUpdatedUUIDHost1)
|
|
preVarsUpdatedUUIDHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID)
|
|
require.NotNil(t, preVarsUpdatedUUIDHost3)
|
|
preVarsUpdatedSerialHost1 := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, preVarsUpdatedSerialHost1)
|
|
preVarsUpdatedSerialHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, preVarsUpdatedSerialHost3)
|
|
|
|
// Update VarUUID.json to remove the variable (same name/identifier, static content)
|
|
declUUIDNowStatic := []byte(`{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {"Echo": "static-uuid-replacement"},
|
|
"Identifier": "com.fleet.var.uuid"
|
|
}`)
|
|
profilesReqVarRemoved := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
|
{Name: "VarUUID.json", Contents: declUUIDNowStatic},
|
|
{Name: "VarSerial.json", Contents: declWithSerial},
|
|
{Name: "Plain.json", Contents: declPlain},
|
|
{Name: "VarIdpUsername.json", Contents: declWithIdpUsername},
|
|
}}
|
|
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqVarRemoved, http.StatusNoContent,
|
|
"team_id", teamIDStr)
|
|
|
|
// Re-read the declaration from DB — content and token should have changed
|
|
dbDeclUUIDUpdated := getDeclaration(t, "VarUUID.json")
|
|
assert.Contains(t, string(dbDeclUUIDUpdated.RawJSON), "static-uuid-replacement")
|
|
assert.NotContains(t, string(dbDeclUUIDUpdated.RawJSON), "$FLEET_VAR")
|
|
assert.NotEqual(t, dbDeclUUID.Token, dbDeclUUIDUpdated.Token)
|
|
// Declaration UUID stays the same (updated in place)
|
|
assert.Equal(t, dbDeclUUID.DeclarationUUID, dbDeclUUIDUpdated.DeclarationUUID)
|
|
|
|
s.awaitTriggerProfileSchedule(t)
|
|
|
|
// Both team hosts get DDM sync (declaration content changed)
|
|
checkDDMSync(mdmDevice1)
|
|
checkDDMSync(mdmDevice3)
|
|
// Global host gets nothing
|
|
checkNoCommands(mdmDevice2)
|
|
|
|
// Token changed (declaration requires re-delivery)
|
|
r, err = mdmDevice1.DeclarativeManagement("tokens")
|
|
require.NoError(t, err)
|
|
tokens = parseTokensResp(t, r)
|
|
assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
|
|
|
|
// variables_updated_at for VarUUID.json is now NULL (no more variables)
|
|
varsUpdatedUUIDAfterRemoval := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUIDUpdated.DeclarationUUID)
|
|
assert.Nil(t, varsUpdatedUUIDAfterRemoval, "variables_updated_at should be NULL after removing variable from declaration (host1)")
|
|
|
|
varsUpdatedUUIDAfterRemovalHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUIDUpdated.DeclarationUUID)
|
|
assert.Nil(t, varsUpdatedUUIDAfterRemovalHost3, "variables_updated_at should be NULL after removing variable from declaration (host3)")
|
|
|
|
// VarSerial.json still has variables — variables_updated_at unchanged on both hosts
|
|
varsUpdatedSerialAfterRemoval := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerialAfterRemoval)
|
|
assert.Equal(t, *preVarsUpdatedSerialHost1, *varsUpdatedSerialAfterRemoval, "VarSerial variables_updated_at should be unchanged on host1")
|
|
varsUpdatedSerialAfterRemovalHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
|
|
require.NotNil(t, varsUpdatedSerialAfterRemovalHost3)
|
|
assert.Equal(t, *preVarsUpdatedSerialHost3, *varsUpdatedSerialAfterRemovalHost3, "VarSerial variables_updated_at should be unchanged on host3")
|
|
}
|
|
|
|
func declarationForTest(identifier string) []byte {
|
|
return []byte(fmt.Sprintf(`
|
|
{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {
|
|
"Echo": "foo"
|
|
},
|
|
"Identifier": "%s"
|
|
}`, identifier))
|
|
}
|
|
|
|
func declarationForTestWithAssetReference(identifier string, assetReference string) []byte {
|
|
return []byte(fmt.Sprintf(`
|
|
{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"Payload": {
|
|
"EchoAssetReference": "%s"
|
|
},
|
|
"Identifier": "%s"
|
|
}`, assetReference, identifier))
|
|
}
|
|
|
|
func declarationForTestWithScope(identifier string, scope fleet.PayloadScope) []byte {
|
|
return fmt.Appendf(nil, `
|
|
{
|
|
"Type": "com.apple.configuration.management.test",
|
|
"PayloadScope": "%s",
|
|
"Payload": {
|
|
"Echo": "foo"
|
|
},
|
|
"Identifier": "%s"
|
|
}`, scope, identifier)
|
|
}
|
|
|
|
func declarationForTestWithType(identifier string, dType string) []byte {
|
|
return []byte(fmt.Sprintf(`
|
|
{
|
|
"Type": "%s",
|
|
"Payload": {
|
|
"Echo": "foo"
|
|
},
|
|
"Identifier": "%s"
|
|
}`, dType, identifier))
|
|
}
|