<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves # Raised by the frontend while building the Edit modal: there was no way to clear a declaration's custom activation. An absent `activation` field meant "keep it" on a labels-only edit but "delete it" when the profile contents were replaced, so clearing wasn't expressible and an ordinary content edit silently dropped the activation. The field is now three-state: | Request | Result | |---|---| | no `activation` key | stored activation left alone | | `activation` as an empty value | removed | | `activation` as a file | replaced | Multipart has no null, so an empty value stands in for one. Note this changes one existing behaviour: replacing a profile's contents without sending an activation used to delete it, and now preserves it. Removal has to be explicit. Anything ambiguous is rejected rather than guessed at, since every ambiguous form would otherwise resolve to deleting the stored activation: | Request | Result | |---|---| | `activation` as a nonempty value | 422 — more likely a malformed upload than a request to delete | | `activation` as a zero-byte file | 422 — a failed upload shouldn't delete anything | | `activation` sent as both a file and a value | 422 — one says replace, the other says remove | The unsupported-profile check also keys on the field being present rather than on it carrying content, so clearing an activation on a Windows, Android or mobileconfig profile is rejected instead of quietly succeeding. On the datastore side, `SetOrUpdateMDMAppleDeclaration` now takes an explicit action (`MDMAppleActivationKeep` / `MDMAppleActivationApply`) instead of inferring intent from the struct. The write is a full replace, so "keep" has to be stated — otherwise preserving the activation would mean reading it back and handing it to the write, which also risked dropping its Fleet variable associations. As a side effect the OS updates cron no longer fires a DELETE for an activation it never had. # 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. - [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] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [ ] QA'd all new/changed functionality manually Integration test covers all three states end to end through the multipart decoder, plus service-level tests for preserve and explicit removal. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Apple MDM declaration updates now support preserving, replacing, or explicitly removing activation settings. * Omitted activation fields leave existing settings unchanged, while empty fields remove them. * Apple OS update declarations retain activation settings by default. * **Bug Fixes** * Labels-only updates no longer unintentionally carry forward activation data. * Invalid, empty, or conflicting activation uploads now receive clear validation errors. * Unsupported profile types now reject activation updates. * **Tests** * Added coverage for activation preservation, replacement, removal, and integration scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1042 lines
42 KiB
Go
1042 lines
42 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"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 TestMDMDDMApple(t *testing.T) {
|
|
ds := CreateMySQLDS(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
fn func(t *testing.T, ds *Datastore)
|
|
}{
|
|
{"ListAppleDDMAssets", testListAppleDDMAssets},
|
|
{"GetAppleDDMAsset", testGetAppleDDMAsset},
|
|
{"GetAppleDDMAssetForDownload", testGetAppleDDMAssetForDownload},
|
|
{"CreateAppleDDMAsset", testCreateAppleDDMAsset},
|
|
{"DeleteAppleDDMAsset", testDeleteAppleDDMAsset},
|
|
{"BatchSetAppleDDMAssets", testBatchSetAppleDDMAssets},
|
|
{"StoreDDMStatusReportSkipsRemoveRows", testStoreDDMStatusReportSkipsRemoveRows},
|
|
{"CleanUpDuplicateRemoveInstallAcrossBatches", testCleanUpDuplicateRemoveInstallAcrossBatches},
|
|
{"ChannelScopeIsolation", testDDMChannelScopeIsolation},
|
|
{"GetAppleDDMAssetForDelivery", testGetAppleDDMAssetForDelivery},
|
|
{"GetAppleDDMAssetsReferencedByDeclarations", testGetAppleDDMAssetsReferencedByDeclarations},
|
|
{"AssetsUpdatedAtRoundTripAndToken", testDDMAssetsUpdatedAtRoundTripAndToken},
|
|
{"UpsertDeclarationAssetReferences", testUpsertDeclarationAssetReferences},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Helper()
|
|
t.Run(c.name, func(t *testing.T) {
|
|
defer TruncateTables(t, ds)
|
|
c.fn(t, ds)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to set up device and enrollment records for a host
|
|
func setupMDMDeviceAndEnrollment(t *testing.T, ds *Datastore, ctx context.Context, hostUUID, hardwareSerial string) {
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `INSERT INTO nano_devices (id, serial_number, authenticate) VALUES (?, ?, ?)`,
|
|
hostUUID, hardwareSerial, "test")
|
|
return err
|
|
})
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx,
|
|
`INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex, enabled, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
hostUUID, hostUUID, "Device", "topic", "push_magic", "token_hex", 1, time.Now())
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Helper function to insert a host declaration
|
|
func insertHostDeclaration(t *testing.T, ds *Datastore, ctx context.Context, hostUUID, declarationUUID, token, status, operationType, identifier string) {
|
|
var statusPtr *string
|
|
if status != "" {
|
|
statusPtr = ptr.String(status)
|
|
}
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO host_mdm_apple_declarations
|
|
(host_uuid, declaration_uuid, status, operation_type, token, declaration_identifier)
|
|
VALUES (?, ?, ?, ?, UNHEX(MD5(?)), ?)`,
|
|
hostUUID, declarationUUID, statusPtr, operationType, token, identifier)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func testStoreDDMStatusReportSkipsRemoveRows(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create a test host
|
|
host, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "test-host-ddm-status",
|
|
UUID: "test-host-uuid-ddm-status",
|
|
HardwareSerial: "ABC123-DDM-STATUS",
|
|
PrimaryIP: "192.168.1.50",
|
|
PrimaryMac: "00:00:00:00:00:50",
|
|
OsqueryHostID: ptr.String("test-host-uuid-ddm-status"),
|
|
NodeKey: ptr.String("test-host-uuid-ddm-status"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
setupMDMDeviceAndEnrollment(t, ds, ctx, host.UUID, host.HardwareSerial)
|
|
|
|
// Insert two rows into host_mdm_apple_declarations:
|
|
// Row A: a remove-operation row (simulating a declaration pending removal)
|
|
// Row B: a normal install-operation row
|
|
insertHostDeclaration(t, ds, ctx, host.UUID, "decl-remove", "shared-token", "pending", "remove", "com.example.remove")
|
|
insertHostDeclaration(t, ds, ctx, host.UUID, "decl-install", "install-token", "pending", "install", "com.example.install")
|
|
|
|
// Query back the HEX tokens from the DB (MDMAppleStoreDDMStatusReport reads tokens as HEX)
|
|
type tokenRow struct {
|
|
DeclarationUUID string `db:"declaration_uuid"`
|
|
Token string `db:"token"`
|
|
}
|
|
var tokens []tokenRow
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &tokens, `
|
|
SELECT declaration_uuid, HEX(token) as token
|
|
FROM host_mdm_apple_declarations
|
|
WHERE host_uuid = ?
|
|
ORDER BY declaration_uuid`, host.UUID)
|
|
})
|
|
require.Len(t, tokens, 2)
|
|
|
|
tokenByDecl := make(map[string]string, 2)
|
|
for _, tok := range tokens {
|
|
tokenByDecl[tok.DeclarationUUID] = tok.Token
|
|
}
|
|
tokenA := tokenByDecl["decl-remove"]
|
|
tokenB := tokenByDecl["decl-install"]
|
|
require.NotEmpty(t, tokenA)
|
|
require.NotEmpty(t, tokenB)
|
|
|
|
// Build the updates slice as if the device is reporting status.
|
|
// The device always reports operation_type='install' for all declarations.
|
|
updates := []*fleet.MDMAppleHostDeclaration{
|
|
{
|
|
Token: tokenA,
|
|
Status: new(fleet.MDMDeliveryStatus),
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
},
|
|
{
|
|
Token: tokenB,
|
|
Status: new(fleet.MDMDeliveryStatus),
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
},
|
|
}
|
|
*updates[0].Status = fleet.MDMDeliveryVerified
|
|
*updates[1].Status = fleet.MDMDeliveryVerified
|
|
|
|
// Call the method under test
|
|
err = ds.MDMAppleStoreDDMStatusReport(ctx, host.UUID, fleet.PayloadScopeSystem, updates)
|
|
require.NoError(t, err)
|
|
|
|
// Assert the end state.
|
|
type resultRow struct {
|
|
DeclarationUUID string `db:"declaration_uuid"`
|
|
Token string `db:"token"`
|
|
Status string `db:"status"`
|
|
OperationType string `db:"operation_type"`
|
|
}
|
|
var remaining []resultRow
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &remaining, `
|
|
SELECT declaration_uuid, HEX(token) as token, status, operation_type
|
|
FROM host_mdm_apple_declarations
|
|
WHERE host_uuid = ?
|
|
ORDER BY declaration_uuid`, host.UUID)
|
|
})
|
|
|
|
// With the fix: only the install row should remain (remove row was skipped then deleted)
|
|
// With the bug: both rows remain, and the remove row was flipped to install/verified
|
|
require.Len(t, remaining, 1, "expected remove row to not survive as install/verified — this is the token collision bug")
|
|
assert.Equal(t, "decl-install", remaining[0].DeclarationUUID)
|
|
assert.Equal(t, "install", remaining[0].OperationType)
|
|
assert.Equal(t, "verified", remaining[0].Status)
|
|
}
|
|
|
|
// testDDMChannelScopeIsolation verifies the four DDM serving queries keep the
|
|
// device (System) and user (User) channels independent: each channel sees only
|
|
// its own declarations, tokens are computed per channel, and a status report on
|
|
// one channel doesn't touch the other channel's rows.
|
|
func testDDMChannelScopeIsolation(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
host, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "test-host-ddm-scope",
|
|
UUID: "test-host-uuid-ddm-scope",
|
|
HardwareSerial: "ABC123-DDM-SCOPE",
|
|
PrimaryIP: "192.168.1.60",
|
|
PrimaryMac: "00:00:00:00:00:60",
|
|
OsqueryHostID: new("test-host-uuid-ddm-scope"),
|
|
NodeKey: new("test-host-uuid-ddm-scope"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
setupMDMDeviceAndEnrollment(t, ds, ctx, host.UUID, host.HardwareSerial)
|
|
|
|
devDecl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "DeviceDecl",
|
|
Identifier: "com.example.device",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.device"}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
userDecl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "UserDecl",
|
|
Identifier: "com.example.user",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.user"}`),
|
|
Scope: fleet.PayloadScopeUser,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
readBinaryToken := func(declUUID string) []byte {
|
|
var tok []byte
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &tok, "SELECT token FROM mdm_apple_declarations WHERE declaration_uuid = ?", declUUID)
|
|
})
|
|
return tok
|
|
}
|
|
devToken := readBinaryToken(devDecl.DeclarationUUID)
|
|
userToken := readBinaryToken(userDecl.DeclarationUUID)
|
|
|
|
pending := fleet.MDMDeliveryPending
|
|
require.NoError(t, ds.BulkUpsertMDMAppleHostDeclarations(ctx, []*fleet.MDMAppleHostDeclaration{
|
|
{
|
|
HostUUID: host.UUID, DeclarationUUID: devDecl.DeclarationUUID, Name: devDecl.Name,
|
|
Identifier: devDecl.Identifier, Status: &pending, OperationType: fleet.MDMOperationTypeInstall,
|
|
Token: string(devToken), Scope: fleet.PayloadScopeSystem,
|
|
},
|
|
{
|
|
HostUUID: host.UUID, DeclarationUUID: userDecl.DeclarationUUID, Name: userDecl.Name,
|
|
Identifier: userDecl.Identifier, Status: &pending, OperationType: fleet.MDMOperationTypeInstall,
|
|
Token: string(userToken), Scope: fleet.PayloadScopeUser,
|
|
},
|
|
}))
|
|
|
|
// Tokens are computed per channel and differ.
|
|
sysTok, err := ds.MDMAppleDDMDeclarationsToken(ctx, host.UUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
usrTok, err := ds.MDMAppleDDMDeclarationsToken(ctx, host.UUID, fleet.PayloadScopeUser)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, sysTok.DeclarationsToken)
|
|
require.NotEmpty(t, usrTok.DeclarationsToken)
|
|
require.NotEqual(t, sysTok.DeclarationsToken, usrTok.DeclarationsToken)
|
|
|
|
// Declaration items are scoped to their channel.
|
|
sysItems, err := ds.MDMAppleDDMDeclarationItems(ctx, host.UUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
require.Len(t, sysItems, 1)
|
|
require.Equal(t, "com.example.device", sysItems[0].Identifier)
|
|
|
|
usrItems, err := ds.MDMAppleDDMDeclarationItems(ctx, host.UUID, fleet.PayloadScopeUser)
|
|
require.NoError(t, err)
|
|
require.Len(t, usrItems, 1)
|
|
require.Equal(t, "com.example.user", usrItems[0].Identifier)
|
|
|
|
// The declaration response respects scope: the user declaration is not served
|
|
// on the device channel and vice versa.
|
|
_, err = ds.MDMAppleDDMDeclarationsResponse(ctx, "com.example.user", host.UUID, fleet.PayloadScopeSystem)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
gotUser, err := ds.MDMAppleDDMDeclarationsResponse(ctx, "com.example.user", host.UUID, fleet.PayloadScopeUser)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userDecl.DeclarationUUID, gotUser.DeclarationUUID)
|
|
|
|
_, err = ds.MDMAppleDDMDeclarationsResponse(ctx, "com.example.device", host.UUID, fleet.PayloadScopeUser)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
|
|
// A status report on the user channel only transitions user-scoped rows.
|
|
verified := fleet.MDMDeliveryVerified
|
|
err = ds.MDMAppleStoreDDMStatusReport(ctx, host.UUID, fleet.PayloadScopeUser, []*fleet.MDMAppleHostDeclaration{
|
|
{Token: fmt.Sprintf("%X", userToken), Status: &verified, OperationType: fleet.MDMOperationTypeInstall},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
type statusRow struct {
|
|
DeclarationUUID string `db:"declaration_uuid"`
|
|
Status string `db:"status"`
|
|
}
|
|
var rows []statusRow
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows, `
|
|
SELECT declaration_uuid, COALESCE(status,'') AS status
|
|
FROM host_mdm_apple_declarations WHERE host_uuid = ? ORDER BY declaration_uuid`, host.UUID)
|
|
})
|
|
statusByUUID := make(map[string]string, len(rows))
|
|
for _, r := range rows {
|
|
statusByUUID[r.DeclarationUUID] = r.Status
|
|
}
|
|
require.Equal(t, "verified", statusByUUID[userDecl.DeclarationUUID], "user-scoped row should be verified by the user-channel report")
|
|
require.Equal(t, "pending", statusByUUID[devDecl.DeclarationUUID], "device-scoped row must be untouched by a user-channel report")
|
|
}
|
|
|
|
func testCleanUpDuplicateRemoveInstallAcrossBatches(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// This exercises cleanUpDuplicateRemoveInstall, which runs inside
|
|
// BulkUpsertMDMAppleHostDeclarations after each batch of host declaration
|
|
// rows is written. The scenario:
|
|
// - D1 is the "old" declaration already marked for removal
|
|
// (operation_type=remove, status=pending) on each host.
|
|
// - D2 is the "new" declaration (same content/token, different UUID — e.g.
|
|
// after a name change caused a delete+reinsert) now being installed.
|
|
// Because the remove and install share a token, the host has nothing to do, so
|
|
// the cleanup deletes the stale remove row and marks the install as verified
|
|
// with resync=1.
|
|
//
|
|
// The remove rows and install rows are written in SEPARATE bulk-upsert calls to
|
|
// prove the cleanup matches against committed DB state, not just the rows in the
|
|
// current batch.
|
|
declJSON := []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.cleanup"}`)
|
|
|
|
// Create the declaration so we have a realistic token to share between the
|
|
// remove (D1) and install (D2) host rows.
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: "decl-new",
|
|
Name: "New Declaration",
|
|
Identifier: "com.example.cleanup",
|
|
RawJSON: declJSON,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Read the raw (binary) token. BulkUpsertMDMAppleHostDeclarations writes the
|
|
// Token field straight into the token column (no UNHEX), so we need the binary
|
|
// value rather than its hex representation.
|
|
var token []byte
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &token,
|
|
"SELECT token FROM mdm_apple_declarations WHERE declaration_uuid = ?", decl.DeclarationUUID)
|
|
})
|
|
|
|
// Create 3 hosts, all enrolled
|
|
hosts := make([]*fleet.Host, 3)
|
|
for i := range 3 {
|
|
hostUUID := fmt.Sprintf("cleanup-host-%d", i)
|
|
newHost, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: fmt.Sprintf("cleanup-host-%d", i),
|
|
UUID: hostUUID,
|
|
HardwareSerial: fmt.Sprintf("CLEANUP-%d", i),
|
|
PrimaryIP: fmt.Sprintf("192.168.10.%d", i+1),
|
|
PrimaryMac: fmt.Sprintf("00:00:00:00:10:%02d", i+1),
|
|
OsqueryHostID: ptr.String(hostUUID),
|
|
NodeKey: ptr.String(hostUUID),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, newHost)
|
|
hosts[i] = newHost
|
|
setupMDMDeviceAndEnrollment(t, ds, ctx, hostUUID, hosts[i].HardwareSerial)
|
|
}
|
|
|
|
pending := fleet.MDMDeliveryPending
|
|
|
|
// First bulk upsert: write the stale "remove" rows for the old declaration (D1).
|
|
// cleanUpDuplicateRemoveInstall is a no-op for this batch because it contains no
|
|
// installs.
|
|
removeRows := make([]*fleet.MDMAppleHostDeclaration, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
removeRows = append(removeRows, &fleet.MDMAppleHostDeclaration{
|
|
HostUUID: h.UUID,
|
|
DeclarationUUID: "decl-old",
|
|
Name: "Old Declaration",
|
|
Identifier: decl.Identifier,
|
|
Status: &pending,
|
|
OperationType: fleet.MDMOperationTypeRemove,
|
|
Token: string(token),
|
|
})
|
|
}
|
|
require.NoError(t, ds.BulkUpsertMDMAppleHostDeclarations(ctx, removeRows))
|
|
|
|
// Second bulk upsert: write the "install" rows for the new declaration (D2). The
|
|
// cleanup runs after this batch and must find the matching remove rows committed
|
|
// by the first batch.
|
|
installRows := make([]*fleet.MDMAppleHostDeclaration, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
installRows = append(installRows, &fleet.MDMAppleHostDeclaration{
|
|
HostUUID: h.UUID,
|
|
DeclarationUUID: decl.DeclarationUUID,
|
|
Name: decl.Name,
|
|
Identifier: decl.Identifier,
|
|
Status: &pending,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Token: string(token),
|
|
})
|
|
}
|
|
require.NoError(t, ds.BulkUpsertMDMAppleHostDeclarations(ctx, installRows))
|
|
|
|
// Assert: for each host, the cleanup should have:
|
|
// 1. Deleted the D1 remove row (same token as D2 install — duplicate remove/install)
|
|
// 2. Marked D2 install as verified with resync=1
|
|
type declRow struct {
|
|
DeclarationUUID string `db:"declaration_uuid"`
|
|
OperationType string `db:"operation_type"`
|
|
Status string `db:"status"`
|
|
Resync bool `db:"resync"`
|
|
Token *string `db:"token"`
|
|
}
|
|
for _, h := range hosts {
|
|
var rows []declRow
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &rows, `
|
|
SELECT declaration_uuid, operation_type, COALESCE(status, '') as status, resync, HEX(token) as token
|
|
FROM host_mdm_apple_declarations
|
|
WHERE host_uuid = ?
|
|
ORDER BY declaration_uuid`, h.UUID)
|
|
})
|
|
|
|
// Build a map by declaration UUID for easier assertions
|
|
rowByDecl := make(map[string]declRow, len(rows))
|
|
for _, r := range rows {
|
|
rowByDecl[r.DeclarationUUID] = r
|
|
}
|
|
|
|
// D1 remove row should NOT exist (cleaned up because same token as D2 install)
|
|
_, d1Exists := rowByDecl["decl-old"]
|
|
assert.False(t, d1Exists, "host %s: D1 remove row should have been cleaned up (same token as D2 install)", h.UUID)
|
|
|
|
// D2 install row should exist as verified with resync=1
|
|
d2Row, d2Exists := rowByDecl[decl.DeclarationUUID]
|
|
if assert.True(t, d2Exists, "host %s: D2 install row should exist", h.UUID) {
|
|
assert.Equal(t, "install", d2Row.OperationType, "host %s: D2 should be install", h.UUID)
|
|
assert.Equal(t, "verified", d2Row.Status, "host %s: D2 should be marked verified by cleanup", h.UUID)
|
|
assert.True(t, d2Row.Resync, "host %s: D2 should have resync=1", h.UUID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testListAppleDDMAssets(t *testing.T, ds *Datastore) {
|
|
t.Run("no assets returns empty list", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assets, err := ds.ListAppleDDMAssets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Empty(t, assets)
|
|
})
|
|
|
|
t.Run("returns assets for requested team", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
// insert helper
|
|
_, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), new(uint(1)))
|
|
require.NoError(t, err)
|
|
|
|
assets, err := ds.ListAppleDDMAssets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Empty(t, assets)
|
|
|
|
assets, err = ds.ListAppleDDMAssets(ctx, new(uint(1)))
|
|
require.NoError(t, err)
|
|
require.Len(t, assets, 1)
|
|
})
|
|
}
|
|
|
|
func testGetAppleDDMAsset(t *testing.T, ds *Datastore) {
|
|
t.Run("returns not found for missing asset", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
asset, err := ds.GetAppleDDMAsset(ctx, "fake-uuid")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
require.Nil(t, asset)
|
|
})
|
|
|
|
t.Run("returns asset for existing asset", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetUUID, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
asset, err := ds.GetAppleDDMAsset(ctx, assetUUID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, asset)
|
|
})
|
|
|
|
t.Run("return error for empty asset uuid", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
asset, err := ds.GetAppleDDMAsset(ctx, "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "asset UUID is required")
|
|
require.Nil(t, asset)
|
|
})
|
|
}
|
|
|
|
func testGetAppleDDMAssetForDownload(t *testing.T, ds *Datastore) {
|
|
t.Run("returns not found for missing asset", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
asset, err := ds.GetAppleDDMAssetForDownload(ctx, "fake-uuid")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
require.Nil(t, asset)
|
|
})
|
|
|
|
t.Run("returns asset values for existing asset", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetName := "asset-1"
|
|
assetUUID, err := ds.CreateAppleDDMAsset(ctx, assetName, "asset.identifier", []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
asset, err := ds.GetAppleDDMAssetForDownload(ctx, assetUUID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, asset)
|
|
require.Equal(t, assetName, asset.Name)
|
|
require.NotNil(t, asset.Data)
|
|
})
|
|
|
|
t.Run("return error for empty asset uuid", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
asset, err := ds.GetAppleDDMAssetForDownload(ctx, "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "asset UUID is required")
|
|
require.Nil(t, asset)
|
|
})
|
|
}
|
|
|
|
func testDeleteAppleDDMAsset(t *testing.T, ds *Datastore) {
|
|
t.Run("returns not found for missing asset", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
err := ds.DeleteAppleDDMAsset(ctx, "fake-uuid")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
})
|
|
|
|
t.Run("returns error for empty asset UUID", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
err := ds.DeleteAppleDDMAsset(ctx, "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "asset UUID is required")
|
|
})
|
|
|
|
t.Run("deletes existing asset", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetUUID, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
err = ds.DeleteAppleDDMAsset(ctx, assetUUID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("returns foreign key error for asset with declaration association", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetUUID, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
// Insert a declaration, and decl<->asset association.
|
|
declUUID := uuid.NewString()
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
DeclarationUUID: declUUID,
|
|
Identifier: "declaration.identifier",
|
|
Name: "decl-name",
|
|
RawJSON: []byte(`{"foo":"bar"}`),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err = q.ExecContext(ctx, `INSERT INTO mdm_apple_declaration_asset_references (declaration_uuid, asset_uuid) VALUES (?, ?)`, decl.DeclarationUUID, assetUUID)
|
|
require.NoError(t, err)
|
|
return nil
|
|
})
|
|
|
|
err = ds.DeleteAppleDDMAsset(ctx, assetUUID)
|
|
require.Error(t, err)
|
|
var foreignKeyErr *foreignKeyError
|
|
require.ErrorAs(t, err, &foreignKeyErr)
|
|
})
|
|
}
|
|
|
|
func testBatchSetAppleDDMAssets(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
assetData := func(typ, identifier, dataURL string) []byte {
|
|
return fmt.Appendf(nil, `{"Type":%q,"Identifier":%q,"Payload":{"Reference":{"DataURL":%q}}}`, typ, identifier, dataURL)
|
|
}
|
|
uploadedAt := func(identifier string) time.Time {
|
|
var ts time.Time
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &ts,
|
|
`SELECT uploaded_at FROM mdm_apple_declaration_assets WHERE team_id = 0 AND identifier = ?`, identifier)
|
|
})
|
|
return ts
|
|
}
|
|
|
|
// Create two new assets.
|
|
changes, err := ds.BatchSetAppleDDMAssets(ctx, nil, []*fleet.MDMAppleDDMAssetToSet{
|
|
{Name: "a", Identifier: "id.a", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.a", "https://example.com/a")},
|
|
{Name: "b", Identifier: "id.b", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.b", "https://example.com/b")},
|
|
})
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []string{"a", "b"}, changes.Created)
|
|
require.Empty(t, changes.Edited)
|
|
require.Empty(t, changes.Deleted)
|
|
assets, err := ds.ListAppleDDMAssets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, assets, 2)
|
|
firstUploaded := uploadedAt("id.a")
|
|
|
|
// Re-applying the same content is a no-op: uploaded_at must not change and
|
|
// no changes are reported.
|
|
changes, err = ds.BatchSetAppleDDMAssets(ctx, nil, []*fleet.MDMAppleDDMAssetToSet{
|
|
{Name: "a", Identifier: "id.a", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.a", "https://example.com/a")},
|
|
{Name: "b", Identifier: "id.b", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.b", "https://example.com/b")},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes.Created)
|
|
require.Empty(t, changes.Edited)
|
|
require.Empty(t, changes.Deleted)
|
|
require.True(t, uploadedAt("id.a").Equal(firstUploaded))
|
|
|
|
// Editing an asset's payload bumps uploaded_at and is reported as edited.
|
|
changes, err = ds.BatchSetAppleDDMAssets(ctx, nil, []*fleet.MDMAppleDDMAssetToSet{
|
|
{Name: "a", Identifier: "id.a", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.a", "https://example.com/a-edited")},
|
|
{Name: "b", Identifier: "id.b", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.b", "https://example.com/b")},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes.Created)
|
|
require.ElementsMatch(t, []string{"a"}, changes.Edited)
|
|
require.Empty(t, changes.Deleted)
|
|
require.True(t, uploadedAt("id.a").After(firstUploaded))
|
|
|
|
// Omitting an asset deletes it and is reported as deleted.
|
|
changes, err = ds.BatchSetAppleDDMAssets(ctx, nil, []*fleet.MDMAppleDDMAssetToSet{
|
|
{Name: "a", Identifier: "id.a", Type: "com.apple.asset.data", Data: assetData("com.apple.asset.data", "id.a", "https://example.com/a-edited")},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes.Created)
|
|
require.Empty(t, changes.Edited)
|
|
require.ElementsMatch(t, []string{"b"}, changes.Deleted)
|
|
assets, err = ds.ListAppleDDMAssets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, assets, 1)
|
|
|
|
// Changing an existing asset's type (same identifier) is rejected.
|
|
_, err = ds.BatchSetAppleDDMAssets(ctx, nil, []*fleet.MDMAppleDDMAssetToSet{
|
|
{Name: "a", Identifier: "id.a", Type: "com.apple.asset.other", Data: assetData("com.apple.asset.other", "id.a", "https://example.com/a-edited")},
|
|
})
|
|
require.Error(t, err)
|
|
var conflictErr *fleet.ConflictError
|
|
require.ErrorAs(t, err, &conflictErr)
|
|
|
|
// Deleting an asset that is still referenced by a declaration is rejected.
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Identifier: "decl.identifier",
|
|
Name: "decl-name",
|
|
RawJSON: []byte(`{"foo":"bar"}`),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
referencedAsset, err := ds.GetAppleDDMAsset(ctx, assets[0].AssetUUID)
|
|
require.NoError(t, err)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx,
|
|
`INSERT INTO mdm_apple_declaration_asset_references (declaration_uuid, asset_uuid) VALUES (?, ?)`,
|
|
decl.DeclarationUUID, referencedAsset.AssetUUID)
|
|
return err
|
|
})
|
|
_, err = ds.BatchSetAppleDDMAssets(ctx, nil, nil)
|
|
require.Error(t, err)
|
|
require.ErrorAs(t, err, &conflictErr)
|
|
require.Contains(t, err.Error(), referencedAsset.Identifier)
|
|
}
|
|
|
|
func testCreateAppleDDMAsset(t *testing.T, ds *Datastore) {
|
|
t.Run("creates asset with valid data", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetUUID, err := ds.CreateAppleDDMAsset(ctx, "valid-asset", "valid-asset-identifier", []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, assetUUID)
|
|
})
|
|
|
|
t.Run("fails to create asset with empty name", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
_, err := ds.CreateAppleDDMAsset(ctx, "", "asset.identifier", []byte(`{"foo":"bar"}`), nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "asset name is required")
|
|
})
|
|
|
|
t.Run("fails to create asset with empty identifier", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
_, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "", []byte(`{"foo":"bar"}`), nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "asset identifier is required")
|
|
})
|
|
|
|
t.Run("fails to create asset with empty data", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
_, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", nil, nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "asset data is required")
|
|
})
|
|
|
|
t.Run("returns already exists error when creating asset with duplicate identifier", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetIdentifier := "conflict.asset.identifier"
|
|
_, err := ds.CreateAppleDDMAsset(ctx, "conflict-asset-1", assetIdentifier, []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.CreateAppleDDMAsset(ctx, "asset-2", assetIdentifier, []byte(`{"foo":"baz"}`), nil)
|
|
require.Error(t, err)
|
|
var alreadyExistsErr *existsError
|
|
require.ErrorAs(t, err, &alreadyExistsErr)
|
|
})
|
|
|
|
t.Run("returns already exists error when creating asset with duplicate name", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
assetName := "conflict-asset-name"
|
|
_, err := ds.CreateAppleDDMAsset(ctx, assetName, "conflict.asset.identifier-one", []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.CreateAppleDDMAsset(ctx, assetName, "conflict.asset.identifier-2", []byte(`{"foo":"baz"}`), nil)
|
|
require.Error(t, err)
|
|
var alreadyExistsErr *existsError
|
|
require.ErrorAs(t, err, &alreadyExistsErr)
|
|
})
|
|
|
|
t.Run("does not conflict across teams", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
assetIdentifier := "no-conflict.asset.identifier"
|
|
assetName := "no-conflict.asset-1"
|
|
_, err := ds.CreateAppleDDMAsset(ctx, assetName, assetIdentifier, []byte(`{"foo":"bar"}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.CreateAppleDDMAsset(ctx, assetName, assetIdentifier, []byte(`{"foo":"baz"}`), new(uint(1)))
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func testGetAppleDDMAssetForDelivery(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
const identifier = "com.example.shared.asset"
|
|
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "ddm-delivery-team"})
|
|
require.NoError(t, err)
|
|
|
|
// Same identifier in two different teams (global vs a real team). The unique
|
|
// key is (team_id, identifier), so both assets can coexist.
|
|
globalAssetUUID, err := ds.CreateAppleDDMAsset(ctx, "global-asset", identifier, []byte(`{"global":true}`), nil)
|
|
require.NoError(t, err)
|
|
teamAssetUUID, err := ds.CreateAppleDDMAsset(ctx, "team-asset", identifier, []byte(`{"team":true}`), &team.ID)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, globalAssetUUID, teamAssetUUID)
|
|
|
|
// A declaration in each team that references its team's asset. Delivery is
|
|
// now scoped through the host's installed declarations, so the asset is only
|
|
// reachable via a declaration that references it.
|
|
globalDecl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "GlobalDecl",
|
|
Identifier: "com.example.globalDecl",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.globalDecl"}`),
|
|
AssetReferenceUUIDs: []string{globalAssetUUID},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
teamDecl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
TeamID: &team.ID,
|
|
Name: "TeamDecl",
|
|
Identifier: "com.example.teamDecl",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.teamDecl"}`),
|
|
AssetReferenceUUIDs: []string{teamAssetUUID},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// A global host that has the global declaration installed, and a team host
|
|
// that has the team declaration installed.
|
|
globalHost, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "ddm-delivery-global",
|
|
UUID: "ddm-delivery-global-uuid",
|
|
HardwareSerial: "DDM-DELIVERY-GLOBAL",
|
|
PrimaryIP: "192.168.1.80",
|
|
PrimaryMac: "00:00:00:00:00:80",
|
|
OsqueryHostID: new("ddm-delivery-global-uuid"),
|
|
NodeKey: new("ddm-delivery-global-uuid"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
teamHost, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "ddm-delivery-team",
|
|
UUID: "ddm-delivery-team-uuid",
|
|
HardwareSerial: "DDM-DELIVERY-TEAM",
|
|
PrimaryIP: "192.168.1.81",
|
|
PrimaryMac: "00:00:00:00:00:81",
|
|
OsqueryHostID: new("ddm-delivery-team-uuid"),
|
|
NodeKey: new("ddm-delivery-team-uuid"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{teamHost.ID})))
|
|
|
|
insertHostDeclaration(t, ds, ctx, globalHost.UUID, globalDecl.DeclarationUUID, "global-token", "verified", "install", globalDecl.Identifier)
|
|
insertHostDeclaration(t, ds, ctx, teamHost.UUID, teamDecl.DeclarationUUID, "team-token", "verified", "install", teamDecl.Identifier)
|
|
|
|
t.Run("returns the asset scoped to the host's team", func(t *testing.T) {
|
|
got, err := ds.GetAppleDDMAssetForDelivery(ctx, identifier, globalHost.UUID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, globalAssetUUID, got.AssetUUID)
|
|
require.JSONEq(t, `{"global":true}`, string(got.Data))
|
|
|
|
got, err = ds.GetAppleDDMAssetForDelivery(ctx, identifier, teamHost.UUID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, teamAssetUUID, got.AssetUUID)
|
|
require.JSONEq(t, `{"team":true}`, string(got.Data))
|
|
})
|
|
|
|
t.Run("does not return an asset the host does not reference", func(t *testing.T) {
|
|
// A host with no declaration referencing the asset gets nothing, even
|
|
// though an asset with this identifier exists in its team.
|
|
bareHost, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "ddm-delivery-bare",
|
|
UUID: "ddm-delivery-bare-uuid",
|
|
HardwareSerial: "DDM-DELIVERY-BARE",
|
|
PrimaryIP: "192.168.1.82",
|
|
PrimaryMac: "00:00:00:00:00:82",
|
|
OsqueryHostID: new("ddm-delivery-bare-uuid"),
|
|
NodeKey: new("ddm-delivery-bare-uuid"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.GetAppleDDMAssetForDelivery(ctx, identifier, bareHost.UUID)
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
})
|
|
|
|
t.Run("does not leak an asset from another team", func(t *testing.T) {
|
|
// A team host that (incorrectly) references the global declaration must
|
|
// not receive the global asset, because the asset's team_id does not
|
|
// match the host's team_id.
|
|
mismatchHost, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "ddm-delivery-mismatch",
|
|
UUID: "ddm-delivery-mismatch-uuid",
|
|
HardwareSerial: "DDM-DELIVERY-MISMATCH",
|
|
PrimaryIP: "192.168.1.83",
|
|
PrimaryMac: "00:00:00:00:00:83",
|
|
OsqueryHostID: new("ddm-delivery-mismatch-uuid"),
|
|
NodeKey: new("ddm-delivery-mismatch-uuid"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{mismatchHost.ID})))
|
|
insertHostDeclaration(t, ds, ctx, mismatchHost.UUID, globalDecl.DeclarationUUID, "mismatch-token", "verified", "install", globalDecl.Identifier)
|
|
|
|
_, err = ds.GetAppleDDMAssetForDelivery(ctx, identifier, mismatchHost.UUID)
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
})
|
|
|
|
t.Run("unknown host is not found", func(t *testing.T) {
|
|
_, err := ds.GetAppleDDMAssetForDelivery(ctx, identifier, "does-not-exist-uuid")
|
|
require.Error(t, err)
|
|
require.True(t, fleet.IsNotFound(err))
|
|
})
|
|
|
|
t.Run("missing identifier is an error", func(t *testing.T) {
|
|
_, err := ds.GetAppleDDMAssetForDelivery(ctx, "", globalHost.UUID)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func testGetAppleDDMAssetsReferencedByDeclarations(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
asset1UUID, err := ds.CreateAppleDDMAsset(ctx, "asset-one", "com.example.asset.one", []byte(`{"a":1}`), nil)
|
|
require.NoError(t, err)
|
|
asset2UUID, err := ds.CreateAppleDDMAsset(ctx, "asset-two", "com.example.asset.two", []byte(`{"a":2}`), nil)
|
|
require.NoError(t, err)
|
|
// Unreferenced asset — must not be returned.
|
|
_, err = ds.CreateAppleDDMAsset(ctx, "asset-three", "com.example.asset.three", []byte(`{"a":3}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
// declA references both assets, declB references only asset1.
|
|
declA, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "DeclA",
|
|
Identifier: "com.example.declA",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.declA"}`),
|
|
AssetReferenceUUIDs: []string{asset1UUID, asset2UUID},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
declB, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "DeclB",
|
|
Identifier: "com.example.declB",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.declB"}`),
|
|
AssetReferenceUUIDs: []string{asset1UUID},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("empty input returns empty slice", func(t *testing.T) {
|
|
got, err := ds.GetAppleDDMAssetsReferencedByDeclarations(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Empty(t, got)
|
|
})
|
|
|
|
t.Run("returns assets referenced by the given declarations, deduped", func(t *testing.T) {
|
|
got, err := ds.GetAppleDDMAssetsReferencedByDeclarations(ctx, []string{declA.DeclarationUUID, declB.DeclarationUUID})
|
|
require.NoError(t, err)
|
|
gotUUIDs := make([]string, 0, len(got))
|
|
for _, a := range got {
|
|
gotUUIDs = append(gotUUIDs, a.AssetUUID)
|
|
}
|
|
// asset1 is referenced by both declarations but must appear once (DISTINCT).
|
|
require.ElementsMatch(t, []string{asset1UUID, asset2UUID}, gotUUIDs)
|
|
})
|
|
|
|
t.Run("single declaration returns only its references", func(t *testing.T) {
|
|
got, err := ds.GetAppleDDMAssetsReferencedByDeclarations(ctx, []string{declB.DeclarationUUID})
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
require.Equal(t, asset1UUID, got[0].AssetUUID)
|
|
})
|
|
}
|
|
|
|
func testUpsertDeclarationAssetReferences(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
asset1UUID, err := ds.CreateAppleDDMAsset(ctx, "upsert-asset-one", "com.example.upsert.one", []byte(`{"a":1}`), nil)
|
|
require.NoError(t, err)
|
|
asset2UUID, err := ds.CreateAppleDDMAsset(ctx, "upsert-asset-two", "com.example.upsert.two", []byte(`{"a":2}`), nil)
|
|
require.NoError(t, err)
|
|
|
|
newDecl := func(refs []string) *fleet.MDMAppleDeclaration {
|
|
return &fleet.MDMAppleDeclaration{
|
|
Name: "UpsertDecl",
|
|
Identifier: "com.example.upsertDecl",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.upsertDecl"}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
AssetReferenceUUIDs: refs,
|
|
}
|
|
}
|
|
|
|
refsFor := func(declUUID string) []string {
|
|
var got []string
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.SelectContext(ctx, q, &got,
|
|
`SELECT asset_uuid FROM mdm_apple_declaration_asset_references WHERE declaration_uuid = ?`, declUUID)
|
|
})
|
|
return got
|
|
}
|
|
|
|
created, err := ds.SetOrUpdateMDMAppleDeclaration(ctx, newDecl([]string{asset1UUID}), nil, fleet.MDMAppleActivationApply)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []string{asset1UUID}, refsFor(created.DeclarationUUID))
|
|
|
|
// Re-sending identical contents keeps the existing declaration UUID; the
|
|
// references must be written against that UUID, not a freshly generated one.
|
|
updated, err := ds.SetOrUpdateMDMAppleDeclaration(ctx, newDecl([]string{asset1UUID}), nil, fleet.MDMAppleActivationApply)
|
|
require.NoError(t, err)
|
|
require.Equal(t, created.DeclarationUUID, updated.DeclarationUUID)
|
|
require.ElementsMatch(t, []string{asset1UUID}, refsFor(created.DeclarationUUID))
|
|
|
|
// Adding a reference on an edit.
|
|
updated, err = ds.SetOrUpdateMDMAppleDeclaration(ctx, newDecl([]string{asset1UUID, asset2UUID}), nil, fleet.MDMAppleActivationApply)
|
|
require.NoError(t, err)
|
|
require.Equal(t, created.DeclarationUUID, updated.DeclarationUUID)
|
|
require.ElementsMatch(t, []string{asset1UUID, asset2UUID}, refsFor(created.DeclarationUUID))
|
|
|
|
// Dropping a reference on an edit removes the stale row.
|
|
_, err = ds.SetOrUpdateMDMAppleDeclaration(ctx, newDecl([]string{asset2UUID}), nil, fleet.MDMAppleActivationApply)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []string{asset2UUID}, refsFor(created.DeclarationUUID))
|
|
|
|
// Dropping all references clears them.
|
|
_, err = ds.SetOrUpdateMDMAppleDeclaration(ctx, newDecl(nil), nil, fleet.MDMAppleActivationApply)
|
|
require.NoError(t, err)
|
|
require.Empty(t, refsFor(created.DeclarationUUID))
|
|
}
|
|
|
|
func testDDMAssetsUpdatedAtRoundTripAndToken(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
host, err := ds.NewHost(ctx, &fleet.Host{
|
|
Hostname: "test-host-assets-token",
|
|
UUID: "test-host-uuid-assets-token",
|
|
HardwareSerial: "ASSETS-TOKEN-1",
|
|
PrimaryIP: "192.168.1.70",
|
|
PrimaryMac: "00:00:00:00:00:70",
|
|
OsqueryHostID: new("test-host-uuid-assets-token"),
|
|
NodeKey: new("test-host-uuid-assets-token"),
|
|
DetailUpdatedAt: time.Now(),
|
|
Platform: "darwin",
|
|
})
|
|
require.NoError(t, err)
|
|
setupMDMDeviceAndEnrollment(t, ds, ctx, host.UUID, host.HardwareSerial)
|
|
|
|
decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
|
|
Name: "AssetTokenDecl",
|
|
Identifier: "com.example.assettoken",
|
|
RawJSON: []byte(`{"Type":"com.apple.configuration.test","Identifier":"com.example.assettoken"}`),
|
|
Scope: fleet.PayloadScopeSystem,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
var token []byte
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &token, "SELECT token FROM mdm_apple_declarations WHERE declaration_uuid = ?", decl.DeclarationUUID)
|
|
})
|
|
|
|
pending := fleet.MDMDeliveryPending
|
|
|
|
// First: install the declaration WITHOUT any assets_updated_at.
|
|
require.NoError(t, ds.BulkUpsertMDMAppleHostDeclarations(ctx, []*fleet.MDMAppleHostDeclaration{
|
|
{
|
|
HostUUID: host.UUID, DeclarationUUID: decl.DeclarationUUID, Name: decl.Name,
|
|
Identifier: decl.Identifier, Status: &pending, OperationType: fleet.MDMOperationTypeInstall,
|
|
Token: string(token), Scope: fleet.PayloadScopeSystem,
|
|
},
|
|
}))
|
|
|
|
tokBefore, err := ds.MDMAppleDDMDeclarationsToken(ctx, host.UUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tokBefore.DeclarationsToken)
|
|
|
|
// Now simulate an asset-only update: same declaration/token, but with
|
|
// assets_updated_at stamped (as the reconciler would do).
|
|
assetsUpdatedAt := time.Now().UTC().Truncate(time.Microsecond)
|
|
require.NoError(t, ds.BulkUpsertMDMAppleHostDeclarations(ctx, []*fleet.MDMAppleHostDeclaration{
|
|
{
|
|
HostUUID: host.UUID, DeclarationUUID: decl.DeclarationUUID, Name: decl.Name,
|
|
Identifier: decl.Identifier, Status: &pending, OperationType: fleet.MDMOperationTypeInstall,
|
|
Token: string(token), Scope: fleet.PayloadScopeSystem, AssetsUpdatedAt: &assetsUpdatedAt,
|
|
},
|
|
}))
|
|
|
|
// assets_updated_at is persisted.
|
|
var stored *time.Time
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &stored,
|
|
"SELECT assets_updated_at FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?",
|
|
host.UUID, decl.DeclarationUUID)
|
|
})
|
|
require.NotNil(t, stored)
|
|
require.True(t, assetsUpdatedAt.Equal(*stored), "expected %s, got %s", assetsUpdatedAt, *stored)
|
|
|
|
// The SQL-computed declarations token must change once assets_updated_at is set,
|
|
// even though the declaration's static token is unchanged. This is what causes
|
|
// the host to re-sync on an asset-only update.
|
|
tokAfter, err := ds.MDMAppleDDMDeclarationsToken(ctx, host.UUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, tokBefore.DeclarationsToken, tokAfter.DeclarationsToken)
|
|
|
|
// And it must match the Go-side EffectiveDDMToken over the same inputs, proving
|
|
// the token endpoint and the declaration-items endpoint agree.
|
|
items, err := ds.MDMAppleDDMDeclarationItems(ctx, host.UUID, fleet.PayloadScopeSystem)
|
|
require.NoError(t, err)
|
|
require.Len(t, items, 1)
|
|
require.NotNil(t, items[0].AssetsUpdatedAt)
|
|
require.True(t, assetsUpdatedAt.Equal(*items[0].AssetsUpdatedAt))
|
|
}
|