<!-- 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 -->
723 lines
28 KiB
Go
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, ¬FoundError{}
|
|
}
|
|
|
|
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, ¬FoundError{}
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
})
|
|
}
|