Files
Rajendra Kadam 25cfac309c Let an edit clear a declaration's activation (#50711)
<!-- 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 -->
2026-08-07 15:36:31 +05:30

723 lines
28 KiB
Go

package service
import (
"context"
"crypto/tls"
"errors"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
nanomdm_mdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
mocksvc "github.com/fleetdm/fleet/v4/server/mock/service"
"github.com/fleetdm/fleet/v4/server/ptr"
svcmock "github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanolib/log/stdlogfmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"howett.net/plist"
)
func setup(t *testing.T) (*mock.Store, *Service) {
ds := new(mock.Store)
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Value: []byte(testCert)},
fleet.MDMAssetCAKey: {Value: []byte(testKey)},
fleet.MDMAssetAPNSKey: {Value: []byte(testKey)},
fleet.MDMAssetAPNSCert: {Value: []byte(testCert)},
}, nil
}
svc := &Service{
ds: ds,
}
return ds, svc
}
func TestMDMAppleEnableFileVaultAndEscrow(t *testing.T) {
ctx := context.Background()
getPayloadWithType := func(mc mobileconfig.Mobileconfig, payloadType string) map[string]interface{} {
var payload struct {
PayloadContent []map[string]interface{}
}
_, err := plist.Unmarshal(mc, &payload)
require.NoError(t, err)
for _, p := range payload.PayloadContent {
if p["PayloadType"] == payloadType {
return p
}
}
return nil
}
t.Run("fails if SCEP is not configured", func(t *testing.T) {
ds := new(mock.Store)
svc := &Service{ds: ds}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return nil, nil
}
err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, nil)
require.Error(t, err)
})
t.Run("fails if the profile can't be saved in the db", func(t *testing.T) {
ds, svc := setup(t)
testErr := errors.New("test")
ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, p fleet.MDMAppleConfigProfile, vars []fleet.FleetVarName) (*fleet.MDMAppleConfigProfile, error) {
return nil, testErr
}
err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, nil)
require.ErrorIs(t, err, testErr)
require.True(t, ds.NewMDMAppleConfigProfileFuncInvoked)
})
t.Run("happy path", func(t *testing.T) {
var teamID uint = 4
ds, svc := setup(t)
ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, p fleet.MDMAppleConfigProfile, vars []fleet.FleetVarName) (*fleet.MDMAppleConfigProfile, error) {
require.Equal(t, &teamID, p.TeamID)
require.Equal(t, p.Identifier, mobileconfig.FleetFileVaultPayloadIdentifier)
require.Equal(t, p.Name, mdm.FleetFileVaultProfileName)
require.Contains(t, string(p.Mobileconfig), `MIID6DCCAdACFGX99Sw4aF2qKGLucoIWQRAXHrs1MA0GCSqGSIb3DQEBCwUAMDUxEzARBgNVBAoMClJlZGlzIFRlc3QxHjAcBgNVBAMMFUNlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0yMTEwMTkxNzM0MzlaFw0yMjEwMTkxNzM0MzlaMCwxEzARBgNVBAoMClJlZGlzIFRlc3QxFTATBgNVBAMMDEdlbmVyaWMtY2VydDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKSHcH8EjSvp3Nm4IHAFxG9DZm8+0h1BwU0OX0VHcJ+Cf+f6h0XYMcMo9LFEpnUJRRMjKrM4mkI75NIIufNBN+GrtqqTPTid8wfOGu/Ufa5EEU1hb2j7AiMlpM6i0+ZysXSNo+Vc/cNZT0PXfyOtJnYm6p9WZM84ID1t2ea0bLwC12cTKv5oybVGtJHh76TRxAR3FeQ9+SY30vUAxYm6oWyYho8rRdKtUSe11pXj6OhxxfTZnsSWn4lo0uBpXai63XtieTVpz74htSNC1bunIGv7//m5F60sH5MrF5JSkPxfCfgqski84ICDSRNlvpT+eMPiygAAJ8zY8wYUXRYFYTUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAAAw+6Uz2bAcXgQ7fQfdOm+T6FLRBcr8PD4ajOvSu/T+HhVVjE26Qt2IBwFEYve2FvDxrBCF8aQYZcyQqnP8bdKebnWAaqL8BbTwLWW+fDuZLO2b4QHjAEdEKKdZC5/FRpQrkerf5CCPTHE+5M17OZg41wdVYnCEwJOkP5pUAVsmwtrSwVeIquy20TZO0qbscDQETf7NIJgW0IXg82wBe53Rv4/wL3Ybq13XVRGYiJrwpaNTfUNgsDWqgwlQ5L2GOLDgg8S2NoF9mWVgCGSp3a2eHW+EmBRQ1OP6EYQtIhKdGLrSndAOMJ2ER1pgHWUFKkWQaZ9i37Dx2j7P5c4/XNeVozcRQcLwKwN+n8k+bwIYcTX0HMOVFYm+WiFi/gjI860Tx853Sc0nkpOXmBCeHSXigGUscgjBYbmJz4iExXuwgawLXKLDKs0yyhLDnKEjmx/Vhz03JpsVFJ84kSWkTZkYsXiG306TxuJCX9zAt1z+6ClieTTGiFY+D8DfkC4H82rlPEtImpZ6rInsMUlAykImpd58e4PMSa+w/wSHXDvwFP7py1Gvz3XvcbGLmpBXblxTUpToqC7zSQJhHOMBBt6XnhcRwd6G9Vj/mQM3FvJIrxtKk8O7FwMJloGivS85OEzCIur5A+bObXbM2pcI8y4ueHE4NtElRBwn859AdB2k=`)
testPayload := getPayloadWithType(p.Mobileconfig, "com.apple.MCX.FileVault2")
require.NotNil(t, testPayload)
require.Equal(t, true, testPayload["Defer"])
require.EqualValues(t, 0, testPayload["DeferForceAtUserLoginMaxBypassAttempts"])
return nil, nil
}
err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, ptr.Uint(teamID))
require.NoError(t, err)
require.True(t, ds.NewMDMAppleConfigProfileFuncInvoked)
})
}
func TestMDMAppleDisableFileVaultAndEscrow(t *testing.T) {
var wantTeamID uint
ds, svc := setup(t)
ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc = func(ctx context.Context, teamID *uint, profileIdentifier string) error {
require.NotNil(t, teamID)
require.Equal(t, wantTeamID, *teamID)
require.Equal(t, mobileconfig.FleetFileVaultPayloadIdentifier, profileIdentifier)
return nil
}
err := svc.MDMAppleDisableFileVaultAndEscrow(context.Background(), ptr.Uint(wantTeamID))
require.NoError(t, err)
require.True(t, ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFuncInvoked)
}
var (
testCert = `-----BEGIN CERTIFICATE-----
MIID6DCCAdACFGX99Sw4aF2qKGLucoIWQRAXHrs1MA0GCSqGSIb3DQEBCwUAMDUx
EzARBgNVBAoMClJlZGlzIFRlc3QxHjAcBgNVBAMMFUNlcnRpZmljYXRlIEF1dGhv
cml0eTAeFw0yMTEwMTkxNzM0MzlaFw0yMjEwMTkxNzM0MzlaMCwxEzARBgNVBAoM
ClJlZGlzIFRlc3QxFTATBgNVBAMMDEdlbmVyaWMtY2VydDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAKSHcH8EjSvp3Nm4IHAFxG9DZm8+0h1BwU0OX0VH
cJ+Cf+f6h0XYMcMo9LFEpnUJRRMjKrM4mkI75NIIufNBN+GrtqqTPTid8wfOGu/U
fa5EEU1hb2j7AiMlpM6i0+ZysXSNo+Vc/cNZT0PXfyOtJnYm6p9WZM84ID1t2ea0
bLwC12cTKv5oybVGtJHh76TRxAR3FeQ9+SY30vUAxYm6oWyYho8rRdKtUSe11pXj
6OhxxfTZnsSWn4lo0uBpXai63XtieTVpz74htSNC1bunIGv7//m5F60sH5MrF5JS
kPxfCfgqski84ICDSRNlvpT+eMPiygAAJ8zY8wYUXRYFYTUCAwEAATANBgkqhkiG
9w0BAQsFAAOCAgEAAAw+6Uz2bAcXgQ7fQfdOm+T6FLRBcr8PD4ajOvSu/T+HhVVj
E26Qt2IBwFEYve2FvDxrBCF8aQYZcyQqnP8bdKebnWAaqL8BbTwLWW+fDuZLO2b4
QHjAEdEKKdZC5/FRpQrkerf5CCPTHE+5M17OZg41wdVYnCEwJOkP5pUAVsmwtrSw
VeIquy20TZO0qbscDQETf7NIJgW0IXg82wBe53Rv4/wL3Ybq13XVRGYiJrwpaNTf
UNgsDWqgwlQ5L2GOLDgg8S2NoF9mWVgCGSp3a2eHW+EmBRQ1OP6EYQtIhKdGLrSn
dAOMJ2ER1pgHWUFKkWQaZ9i37Dx2j7P5c4/XNeVozcRQcLwKwN+n8k+bwIYcTX0H
MOVFYm+WiFi/gjI860Tx853Sc0nkpOXmBCeHSXigGUscgjBYbmJz4iExXuwgawLX
KLDKs0yyhLDnKEjmx/Vhz03JpsVFJ84kSWkTZkYsXiG306TxuJCX9zAt1z+6Clie
TTGiFY+D8DfkC4H82rlPEtImpZ6rInsMUlAykImpd58e4PMSa+w/wSHXDvwFP7py
1Gvz3XvcbGLmpBXblxTUpToqC7zSQJhHOMBBt6XnhcRwd6G9Vj/mQM3FvJIrxtKk
8O7FwMJloGivS85OEzCIur5A+bObXbM2pcI8y4ueHE4NtElRBwn859AdB2k=
-----END CERTIFICATE-----`
testKey = testingKey(`-----BEGIN RSA TESTING KEY-----
MIIEogIBAAKCAQEApIdwfwSNK+nc2bggcAXEb0Nmbz7SHUHBTQ5fRUdwn4J/5/qH
Rdgxwyj0sUSmdQlFEyMqsziaQjvk0gi580E34au2qpM9OJ3zB84a79R9rkQRTWFv
aPsCIyWkzqLT5nKxdI2j5Vz9w1lPQ9d/I60mdibqn1ZkzzggPW3Z5rRsvALXZxMq
/mjJtUa0keHvpNHEBHcV5D35JjfS9QDFibqhbJiGjytF0q1RJ7XWlePo6HHF9Nme
xJafiWjS4GldqLrde2J5NWnPviG1I0LVu6cga/v/+bkXrSwfkysXklKQ/F8J+Cqy
SLzggINJE2W+lP54w+LKAAAnzNjzBhRdFgVhNQIDAQABAoIBAAtUbFHC3XnVq+iu
PkWYkBNdX9NvTwbGvWnyAGuD5OSHFwnBfck4fwzCaD9Ay/mpPsF3nXwj/LNs7m/s
O+ndZty6d2S9qOyaK98wuTgkuNbkRxC+Ee73wgjrkbLNEax/32p4Sn4D7lGid8vj
LhUl2k0ult+MEnsWkVnJk8TITeiQaT2AHhMr3HKdaI86hJJfam3wEBiLBglnnKqA
TInMqHoudnFOn/C8iVCFuHCE0oo1dMalbc4rlZuRBqezVhbSMWPLypMVXQb7eixM
ScJ3m8+DooGDSIe+EW/afhN2VnFbrhQC9/DlxGfwTwsUseWv7pgp53ufyyAzzydn
2plW/4ECgYEA1Va5RzSUDxr75JX003YZiBcYrG268vosiNYWRhE7frvn5EorZBRW
t4R70Y2gcXA10aPHzpbq40t6voWtpkfynU3fyRzbBmwfiWLEgckrYMwtcNz8nhG2
ETAg4LXO9CufbwuDa66h76TpkBzQVNc5TSbBUr/apLDWjKPMz6qW7VUCgYEAxW4K
Yqp3NgJkC5DhuD098jir9AH96hGhUryOi2CasCvmbjWCgWdolD7SRZJfxOXFOtHv
7Dkp9glA1Cg/nSmEHKslaTJfBIWK+5rqVD6k6kZE/+4QQWQtUxXXVgGINnGrnPvo
6MlRJxqGUtYJ0GRTFJP4Py0gwuzf5BMIwe+fpGECgYAOhLRfMCjTTlbOG5ZpvaPH
Kys2sNEEMBpPxaIGaq3N1iPV2WZSjT/JhW6XuDevAJ/pAGhcmtCpXz2fMaG7qzHL
mr0cBqaxLTKIOvx8iKA3Gi4NfDyE1Ve6m7fhEv5eh4l2GSZ8cYn7sRFkCVH0NCFm
KrkFVKEgjBhNwefySf2zcQKBgHDVPgw7nlv4q9LMX6RbI98eMnAG/2XZ45gUeWcA
tAeBX3WXEVoBjoxDBwuJ5z/xjXHbb8JSvT+G9E0MH6cjhgSYb44aoqFD7TV0yP2S
u8/Ej0SxewrURO8aKXJW99Edz9WtRuRbwgyWJTSMbRlzbOPy2UrJ8NJWbHK9yiCE
YXmhAoGAA3QUiCCl11c1C4VsF68Fa2i7qwnty3fvFidZpW3ds0tzZdIvkpRLp5+u
XAJ5+zStdEGdnu0iXALQlY7ektawXguT/zYKg3nfS9RMGW6CxZotn4bqfQwDuttf
b1xn1jGQd/o0xFf9ojpDNy6vNojidQGHh6E3h0GYvxbnQmVNq5U=
-----END RSA TESTING KEY-----`)
)
// prevent static analysis tools from raising issues due to detection of
// private key in code.
func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
func TestCountABMTokensAuth(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
ctx := context.Background()
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
svc := Service{ds: ds, authz: authorizer}
ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) {
return 5, nil
}
t.Run("CountABMTokens", func(t *testing.T) {
cases := []struct {
desc string
user *fleet.User
shoudFailWithAuth bool
}{
{"no role", test.UserNoRoles, true},
{"gitops can read", test.UserGitOps, false},
{"maintainer can read", test.UserMaintainer, false},
{"observer can read", test.UserObserver, false},
{"observer+ can read", test.UserObserverPlus, false},
{"admin can read", test.UserAdmin, false},
{"tm1 gitops can read", test.UserTeamGitOpsTeam1, false},
{"tm1 maintainer can read", test.UserTeamMaintainerTeam1, false},
{"tm1 observer can read", test.UserTeamObserverTeam1, false},
{"tm1 observer+ can read", test.UserTeamObserverPlusTeam1, false},
{"tm1 admin can read", test.UserTeamAdminTeam1, false},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
ctx = test.UserContext(ctx, c.user)
count, err := svc.CountABMTokens(ctx)
checkAuthErr(t, c.shoudFailWithAuth, err)
if !c.shoudFailWithAuth {
assert.EqualValues(t, 5, count)
}
})
}
})
}
func TestClearPasscode(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
// Set up the real commander with mocked storage and pusher.
mdmStorage := &mdmmock.MDMAppleStore{}
pushProvider := &svcmock.APNSPushProvider{}
pushProvider.PushFunc = func(_ context.Context, pushes []*nanomdm_mdm.Push) (map[string]*nanomdm_push.Response, error) {
res := make(map[string]*nanomdm_push.Response, len(pushes))
for _, p := range pushes {
res[p.Token.String()] = &nanomdm_push.Response{Id: "ok"}
}
return res, nil
}
pushFactory := &svcmock.APNSPushProviderFactory{}
pushFactory.NewPushProviderFunc = func(*tls.Certificate) (nanomdm_push.PushProvider, error) {
return pushProvider, nil
}
pusher := nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushFactory, stdlogfmt.New())
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
svc := Service{ds: ds, authz: authorizer, mdmAppleCommander: commander, Service: &mocksvc.Service{
NewActivityFunc: func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
},
}}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
// Common mdmStorage mocks for enqueue + push.
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) {
return nil, nil
}
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targets []string) (map[string]*nanomdm_mdm.Push, error) {
pushes := make(map[string]*nanomdm_mdm.Push, len(targets))
for _, uuid := range targets {
pushes[uuid] = &nanomdm_mdm.Push{
PushMagic: "magic" + uuid,
Token: []byte("token" + uuid),
Topic: "topic" + uuid,
}
}
return pushes, nil
}
mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
cert, err := tls.LoadX509KeyPair("../../../server/service/testdata/server.pem", "../../../server/service/testdata/server.key")
return &cert, "", err
}
mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) {
return false, nil
}
t.Run("authorization", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{UnlockToken: new("fake-token")}, nil
}
cases := []struct {
desc string
user *fleet.User
shoudFailWithAuth bool
}{
{"no role", test.UserNoRoles, true},
{"observer", test.UserObserver, true},
{"observer+", test.UserObserverPlus, true},
{"technician", test.UserTechnician, true},
{"gitops", test.UserGitOps, true},
{"maintainer", test.UserMaintainer, false},
{"admin", test.UserAdmin, false},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
ctx := test.UserContext(t.Context(), c.user)
_, err := svc.ClearPasscode(ctx, 1)
checkAuthErr(t, c.shoudFailWithAuth, err)
})
}
})
t.Run("happy path ipados", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, UUID: "host-uuid-1", Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
mdmStorage.EnqueueCommandFuncInvoked = false
})
t.Run("happy path ios", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, UUID: "host-uuid-2", Platform: "ios"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
mdmStorage.EnqueueCommandFuncInvoked = false
})
t.Run("non-apple platform", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "windows"}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "only supported on Apple mobile platforms")
})
t.Run("macOS not supported", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "darwin"}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "ClearPasscode command is only available for iOS and iPadOS. Unable to issue ClearPasscode command.")
})
t.Run("MDM not enabled", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "ipados"}, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: false}}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "Apple MDM must be turned on to use Clear passcode.")
// Restore for subsequent tests.
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
})
t.Run("personal enrollment", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{IsPersonalEnrollment: true}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "Unlock token is not available")
})
t.Run("enqueue command error", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, UUID: "host-uuid-3", Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) {
return nil, errors.New("enqueue failed")
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
assert.Contains(t, err.Error(), "enqueue failed")
// Restore for subsequent tests.
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) {
return nil, nil
}
})
t.Run("host not found", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return nil, &notFoundError{}
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 999)
require.Error(t, err)
})
}
func TestUpdateABMTokenTeams(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
ctx := test.UserContext(t.Context(), test.UserAdmin)
// Set up the real commander with mocked storage and pusher.
mdmStorage := &mdmmock.MDMAppleStore{}
pushProvider := &svcmock.APNSPushProvider{}
pushProvider.PushFunc = func(_ context.Context, pushes []*nanomdm_mdm.Push) (map[string]*nanomdm_push.Response, error) {
res := make(map[string]*nanomdm_push.Response, len(pushes))
for _, p := range pushes {
res[p.Token.String()] = &nanomdm_push.Response{Id: "ok"}
}
return res, nil
}
pushFactory := &svcmock.APNSPushProviderFactory{}
pushFactory.NewPushProviderFunc = func(*tls.Certificate) (nanomdm_push.PushProvider, error) {
return pushProvider, nil
}
pusher := nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushFactory, stdlogfmt.New())
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
svc := Service{ds: ds, authz: authorizer, mdmAppleCommander: commander, Service: &mocksvc.Service{
NewActivityFunc: func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
},
}}
orgName := "Fake Organization"
tokenID := uint(1)
abmToken := &fleet.ABMToken{ID: tokenID, OrganizationName: orgName}
ds.GetABMTokenByIDFunc = func(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) {
return abmToken, nil
}
ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error {
return nil
}
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, AppleBusinessManager: optjson.SetSlice([]fleet.MDMAppleABMAssignmentInfo{
{OrganizationName: orgName},
})}}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return appCfg, nil
}
var updatedAppCfg *fleet.AppConfig
ds.SaveAppConfigFunc = func(ctx context.Context, cfg *fleet.AppConfig) error {
updatedAppCfg = cfg
return nil
}
validTeamID := new(uint(2))
validTeamName := "Valid Team"
invalidTeamID := new(uint(3))
teamLiteCalls := 0
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
teamLiteCalls++
if tid == *validTeamID {
return &fleet.TeamLite{ID: *validTeamID, Name: validTeamName}, nil
}
return nil, &notFoundError{}
}
t.Run("team ids is validated and updated", func(t *testing.T) {
teamLiteCalls = 0
ds.SaveAppConfigFuncInvoked = false
token, err := svc.UpdateABMTokenTeams(ctx, tokenID, validTeamID, validTeamID, validTeamID, validTeamID)
require.NoError(t, err)
assert.Equal(t, validTeamID, token.BYODDefaultTeamID)
assert.Equal(t, validTeamID, token.MacOSDefaultTeamID)
assert.Equal(t, validTeamID, token.IOSDefaultTeamID)
assert.Equal(t, validTeamID, token.IPadOSDefaultTeamID)
assert.Equal(t, 4, teamLiteCalls)
require.True(t, ds.SaveAppConfigFuncInvoked)
var appCfgToken fleet.MDMAppleABMAssignmentInfo
for _, tok := range updatedAppCfg.MDM.AppleBusinessManager.Value {
if tok.OrganizationName == orgName {
appCfgToken = tok
break
}
}
assert.Equal(t, validTeamName, appCfgToken.BYODTeam)
assert.Equal(t, validTeamName, appCfgToken.MacOSTeam)
assert.Equal(t, validTeamName, appCfgToken.IOSTeam)
assert.Equal(t, validTeamName, appCfgToken.IpadOSTeam)
})
t.Run("invalid team id returns error", func(t *testing.T) {
teamLiteCalls = 0
_, err := svc.UpdateABMTokenTeams(ctx, tokenID, validTeamID, validTeamID, validTeamID, invalidTeamID)
require.Error(t, err)
})
t.Run("does not validate nil team ids", func(t *testing.T) {
teamLiteCalls = 0
ds.SaveAppConfigFuncInvoked = false
appCfg.MDM.AppleBusinessManager = optjson.SetSlice([]fleet.MDMAppleABMAssignmentInfo{
{OrganizationName: orgName, MacOSTeam: validTeamName, IOSTeam: validTeamName, IpadOSTeam: validTeamName, BYODTeam: validTeamName},
})
abmToken.MacOSDefaultTeamID = validTeamID
abmToken.IOSDefaultTeamID = validTeamID
abmToken.IPadOSDefaultTeamID = validTeamID
abmToken.BYODDefaultTeamID = validTeamID
abmToken.MacOSTeam.Name = validTeamName
abmToken.MacOSTeam.ID = *validTeamID
abmToken.IOSTeam.Name = validTeamName
abmToken.IOSTeam.ID = *validTeamID
abmToken.IPadOSTeam.Name = validTeamName
abmToken.IPadOSTeam.ID = *validTeamID
abmToken.BYODTeam.Name = validTeamName
abmToken.BYODTeam.ID = *validTeamID
token, err := svc.UpdateABMTokenTeams(ctx, tokenID, nil, nil, nil, nil)
require.NoError(t, err)
assert.Nil(t, token.BYODDefaultTeamID)
assert.Nil(t, token.MacOSDefaultTeamID)
assert.Nil(t, token.IOSDefaultTeamID)
assert.Nil(t, token.IPadOSDefaultTeamID)
assert.Equal(t, 0, teamLiteCalls) // no calls to TeamLite since all team ids are nil
require.True(t, ds.SaveAppConfigFuncInvoked)
var appCfgToken fleet.MDMAppleABMAssignmentInfo
for _, tok := range updatedAppCfg.MDM.AppleBusinessManager.Value {
if tok.OrganizationName == orgName {
appCfgToken = tok
break
}
}
// Validate we clear out the "No team"
assert.Empty(t, appCfgToken.BYODTeam)
assert.Empty(t, appCfgToken.MacOSTeam)
assert.Empty(t, appCfgToken.IOSTeam)
assert.Empty(t, appCfgToken.IpadOSTeam)
})
t.Run("updates app config with new entry if not present", func(t *testing.T) {
appCfg.MDM.AppleBusinessManager = optjson.SetSlice([]fleet.MDMAppleABMAssignmentInfo{})
token, err := svc.UpdateABMTokenTeams(ctx, tokenID, validTeamID, validTeamID, validTeamID, validTeamID)
require.NoError(t, err)
assert.Equal(t, validTeamID, token.BYODDefaultTeamID)
assert.Equal(t, validTeamID, token.MacOSDefaultTeamID)
assert.Equal(t, validTeamID, token.IOSDefaultTeamID)
assert.Equal(t, validTeamID, token.IPadOSDefaultTeamID)
require.True(t, ds.SaveAppConfigFuncInvoked)
var appCfgToken fleet.MDMAppleABMAssignmentInfo
for _, tok := range updatedAppCfg.MDM.AppleBusinessManager.Value {
if tok.OrganizationName == orgName {
appCfgToken = tok
break
}
}
assert.Equal(t, validTeamName, appCfgToken.BYODTeam)
assert.Equal(t, validTeamName, appCfgToken.MacOSTeam)
assert.Equal(t, validTeamName, appCfgToken.IOSTeam)
assert.Equal(t, validTeamName, appCfgToken.IpadOSTeam)
})
}
func TestMDMAppleEditedAppleOSUpdatesDeclaration(t *testing.T) {
ctx := context.Background()
teamID := uint(1)
// captured records what the datastore was handed, so the tests assert on the
// generated declaration rather than on a real write.
type captured struct {
decl *fleet.MDMAppleDeclaration
vars []fleet.FleetVarName
deleted string
labels []string
}
newSvc := func() (*Service, *captured) {
got := &captured{}
ds := new(mock.Store)
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
got.labels = names
ids := make(map[string]uint, len(names))
for i, name := range names {
ids[name] = uint(i + 1) //nolint:gosec
}
return ids, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, decl *fleet.MDMAppleDeclaration,
usesFleetVars []fleet.FleetVarName, activationAction fleet.MDMAppleActivationAction,
) (*fleet.MDMAppleDeclaration, error) {
got.decl = decl
got.vars = usesFleetVars
decl.DeclarationUUID = "decl-uuid"
return decl, nil
}
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, declTeamID *uint, name string) error {
got.deleted = name
return nil
}
return &Service{ds: ds}, got
}
// Each platform gets its own declaration name and built-in label; a mix-up
// would send the OS update declaration to the wrong devices.
platforms := []struct {
name string
device fleet.AppleDevice
declName string
labelName string
}{
{"macos", fleet.MacOS, mdm.FleetMacOSUpdatesProfileName, fleet.BuiltinLabelMacOS14Plus},
{"ios", fleet.IOS, mdm.FleetIOSUpdatesProfileName, fleet.BuiltinLabelIOS},
{"ipados", fleet.IPadOS, mdm.FleetIPadOSUpdatesProfileName, fleet.BuiltinLabelIPadOS},
}
t.Run("latest emits Fleet variable placeholders", func(t *testing.T) {
for _, p := range platforms {
t.Run(p.name, func(t *testing.T) {
svc, got := newSvc()
err := svc.mdmAppleEditedAppleOSUpdates(ctx, &teamID, p.device, fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion),
DeadlineDays: optjson.SetInt(14),
})
require.NoError(t, err)
require.NotNil(t, got.decl)
require.Empty(t, got.deleted)
// The literal placeholder text matters: it is what gets substituted
// per host at declaration fetch time.
require.Contains(t, string(got.decl.RawJSON), `"TargetOSVersion": "$FLEET_VAR_HOST_TARGET_OS_VERSION"`)
require.Contains(t, string(got.decl.RawJSON), `"TargetLocalDateTime": "${FLEET_VAR_HOST_TARGET_OS_DEADLINE}T12:00:00"`)
// Without these the declaration is stored but never expanded.
require.ElementsMatch(t, []fleet.FleetVarName{
fleet.FleetVarHostTargetOSVersion,
fleet.FleetVarHostTargetOSDeadline,
}, got.vars)
require.Equal(t, p.declName, got.decl.Name)
require.Equal(t, []string{p.labelName}, got.labels)
})
}
})
t.Run("specific version emits literal values and no variables", func(t *testing.T) {
for _, p := range platforms {
t.Run(p.name, func(t *testing.T) {
svc, got := newSvc()
err := svc.mdmAppleEditedAppleOSUpdates(ctx, &teamID, p.device, fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("15.7.8"),
Deadline: optjson.SetString("2026-09-01"),
})
require.NoError(t, err)
require.NotNil(t, got.decl)
require.Contains(t, string(got.decl.RawJSON), `"TargetOSVersion": "15.7.8"`)
require.Contains(t, string(got.decl.RawJSON), `"TargetLocalDateTime": "2026-09-01T12:00:00"`)
require.NotContains(t, string(got.decl.RawJSON), "FLEET_VAR_")
require.Empty(t, got.vars)
require.Equal(t, p.declName, got.decl.Name)
require.Equal(t, []string{p.labelName}, got.labels)
})
}
})
t.Run("disabled deletes the declaration", func(t *testing.T) {
for _, p := range platforms {
t.Run(p.name, func(t *testing.T) {
svc, got := newSvc()
err := svc.mdmAppleEditedAppleOSUpdates(ctx, &teamID, p.device, fleet.AppleOSUpdateSettings{})
require.NoError(t, err)
require.Nil(t, got.decl, "no declaration should be written when OS updates are off")
require.Equal(t, p.declName, got.deleted)
})
}
})
}