885 lines
42 KiB
Go
885 lines
42 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
|
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/api/androidmanagement/v1"
|
|
)
|
|
|
|
func (s *integrationMDMTestSuite) TestVPPAppleManagedAppConfiguration() {
|
|
t := s.T()
|
|
s.setSkipWorkerJobs(t)
|
|
ctx := context.Background()
|
|
|
|
// VPP setup: token + team association.
|
|
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-apple-config-team"})
|
|
require.NoError(t, err)
|
|
|
|
orgName := "Fleet Device Management Inc."
|
|
token := "applemcptoken"
|
|
expDate := time.Now().Add(200 * time.Hour).UTC().Round(time.Second).Format(fleet.VPPTimeFormat)
|
|
tokenJSON := fmt.Sprintf(`{"expDate":%q,"token":%q,"orgName":%q}`, expDate, token, orgName)
|
|
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
|
|
|
// Adam IDs "2" and "3" come pre-registered by the mock VPP server with iOS/iPadOS metadata.
|
|
const iosAdamID = "2"
|
|
const ipadOSAdamID = "3"
|
|
|
|
var validToken uploadVPPTokenResponse
|
|
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken",
|
|
[]byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
|
|
|
|
var getVPPTokenResp getVPPTokensResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp)
|
|
|
|
var resPatchVPP patchVPPTokensTeamsResponse
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID),
|
|
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
|
|
|
|
const validPlist = `<dict><key>ServerURL</key><string>https://example.com</string></dict>`
|
|
const validPlist2 = `<dict><key>ServerURL</key><string>https://other.example.com</string><key>HostUUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`
|
|
|
|
// Helper: encode an XML string as a JSON string (the form clients send).
|
|
asJSONString := func(s string) json.RawMessage {
|
|
b, err := json.Marshal(s)
|
|
require.NoError(t, err)
|
|
return json.RawMessage(b)
|
|
}
|
|
|
|
// Helper: read the stored configuration directly from the datastore.
|
|
readStoredConfig := func(adamID string, platform fleet.InstallableDevicePlatform) []byte {
|
|
got, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), platform, adamID, team.ID)
|
|
require.NoError(t, err)
|
|
return got
|
|
}
|
|
|
|
// 1. Add iOS app with valid plist configuration → 200, stored, activity emitted.
|
|
var addResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
AppStoreID: iosAdamID,
|
|
Platform: fleet.IOSPlatform,
|
|
Configuration: asJSONString(validPlist),
|
|
}, http.StatusOK, &addResp)
|
|
require.NotZero(t, addResp.TitleID)
|
|
|
|
require.Equal(t, []byte(validPlist), readStoredConfig(iosAdamID, fleet.IOSPlatform))
|
|
|
|
// 2. Update iOS app with new configuration that includes an allowed Fleet variable.
|
|
var updResp updateAppStoreAppResponse
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(validPlist2),
|
|
}, http.StatusOK, &updResp)
|
|
require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform))
|
|
|
|
// GET title returns the iOS configuration as a JSON string of plist; unmarshal to recover the raw plist bytes.
|
|
var titleResp getSoftwareTitleResponse
|
|
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID),
|
|
&getSoftwareTitleRequest{ID: addResp.TitleID, TeamID: &team.ID},
|
|
http.StatusOK, &titleResp, "fleet_id", fmt.Sprint(team.ID))
|
|
require.NotNil(t, titleResp.SoftwareTitle.AppStoreApp)
|
|
var gotPlist string
|
|
require.NoError(t, json.Unmarshal(titleResp.SoftwareTitle.AppStoreApp.Configuration, &gotPlist))
|
|
require.Equal(t, validPlist2, gotPlist)
|
|
|
|
// 3. Update iOS app omitting `configuration` field → no change.
|
|
updResp = updateAppStoreAppResponse{}
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
|
|
&updateAppStoreAppRequest{TeamID: &team.ID, SelfService: new(true)}, http.StatusOK, &updResp)
|
|
require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform))
|
|
|
|
// 3b. Update iOS app with `configuration: null` → row deleted (clear semantics
|
|
// must match the batch path; previously the single-app PATCH stored empty bytes).
|
|
updResp = updateAppStoreAppResponse{}
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
|
|
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: json.RawMessage(`null`)},
|
|
http.StatusOK, &updResp)
|
|
_, err = s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, team.ID)
|
|
require.True(t, fleet.IsNotFound(err), "expected configuration row to be deleted on null PATCH, got %v", err)
|
|
|
|
// Re-set the configuration so the rest of the test continues with state.
|
|
updResp = updateAppStoreAppResponse{}
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
|
|
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(validPlist2)},
|
|
http.StatusOK, &updResp)
|
|
require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform))
|
|
|
|
// 4. Add iPadOS app with malformed XML → 422.
|
|
res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
AppStoreID: ipadOSAdamID,
|
|
Platform: fleet.IPadOSPlatform,
|
|
Configuration: asJSONString(`not actually a plist`),
|
|
}, http.StatusUnprocessableEntity)
|
|
require.Contains(t, extractServerErrorText(res.Body), "invalid plist")
|
|
|
|
// 5. Add iOS app with disallowed Fleet variable → 422.
|
|
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
AppStoreID: ipadOSAdamID,
|
|
Platform: fleet.IPadOSPlatform,
|
|
Configuration: asJSONString(`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`),
|
|
}, http.StatusUnprocessableEntity)
|
|
require.Contains(t, extractServerErrorText(res.Body), "$FLEET_VAR_NDES_SCEP_CHALLENGE")
|
|
|
|
// Update iOS app with malformed XML → 422.
|
|
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(`not actually a plist`),
|
|
}, http.StatusUnprocessableEntity)
|
|
require.Contains(t, extractServerErrorText(res.Body), "invalid plist")
|
|
|
|
// Update iOS app with disallowed Fleet variable → 422.
|
|
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`),
|
|
}, http.StatusUnprocessableEntity)
|
|
require.Contains(t, extractServerErrorText(res.Body), "$FLEET_VAR_NDES_SCEP_CHALLENGE")
|
|
|
|
// macOS adam ID — pre-registered as a macOS-only app in the mock VPP server.
|
|
const macosAdamID = "1"
|
|
|
|
requireNoStoredConfig := func(platform fleet.InstallableDevicePlatform, adamID string, teamID uint) {
|
|
_, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), platform, adamID, teamID)
|
|
require.True(t, fleet.IsNotFound(err), "expected not found, got %v", err)
|
|
}
|
|
|
|
// 6. Add macOS app with configuration → 200, configuration silently dropped.
|
|
var addMacResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
AppStoreID: macosAdamID,
|
|
Platform: fleet.MacOSPlatform,
|
|
Configuration: asJSONString(validPlist),
|
|
}, http.StatusOK, &addMacResp)
|
|
require.NotZero(t, addMacResp.TitleID)
|
|
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID)
|
|
|
|
// 7. Update macOS app with configuration → 200, configuration still not stored.
|
|
var updMacResp updateAppStoreAppResponse
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addMacResp.TitleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(validPlist),
|
|
}, http.StatusOK, &updMacResp)
|
|
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID)
|
|
|
|
// 8. Add macOS app with malformed XML → 200 (silent drop must come before validation).
|
|
const macosAdamIDInvalid = "2"
|
|
var addMacInvalidResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
AppStoreID: macosAdamIDInvalid,
|
|
Platform: fleet.MacOSPlatform,
|
|
Configuration: asJSONString(`not actually a plist`),
|
|
}, http.StatusOK, &addMacInvalidResp)
|
|
require.NotZero(t, addMacInvalidResp.TitleID)
|
|
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamIDInvalid, team.ID)
|
|
|
|
// 9. Update macOS app with malformed XML → 200, still no row.
|
|
var updMacInvalidResp updateAppStoreAppResponse
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addMacResp.TitleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(`not actually a plist`),
|
|
}, http.StatusOK, &updMacInvalidResp)
|
|
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID)
|
|
|
|
t.Run("BatchAssociateVPPApps", func(t *testing.T) {
|
|
// Use a fresh team so batch "replace all" doesn't clobber the prior state.
|
|
batchTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-apple-config-batch-team"})
|
|
require.NoError(t, err)
|
|
|
|
var resPatchVPPBatch patchVPPTokensTeamsResponse
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID),
|
|
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, batchTeam.ID}}, http.StatusOK, &resPatchVPPBatch)
|
|
|
|
var batchResp batchAssociateAppStoreAppsResponse
|
|
|
|
// iOS in the same batch proves the path actually ran, so a missing macOS row isn't a no-op.
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)},
|
|
{AppStoreID: macosAdamID, Platform: fleet.MacOSPlatform, Configuration: asJSONString(validPlist)},
|
|
},
|
|
}, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name)
|
|
|
|
// iOS config IS stored — confirms the batch wrote configurations.
|
|
iosCfg, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, batchTeam.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte(validPlist), iosCfg)
|
|
|
|
// macOS config silently dropped.
|
|
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, batchTeam.ID)
|
|
|
|
// Sanity: macOS app IS associated — silent drop applies to config only, not the app association.
|
|
macMeta, err := s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, macosAdamID, macMeta.AdamID)
|
|
|
|
// Remove macOS via batch by omitting it from the payload.
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)},
|
|
},
|
|
}, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name)
|
|
|
|
_, err = s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID)
|
|
require.True(t, fleet.IsNotFound(err), "expected macOS app to be removed, got %v", err)
|
|
|
|
// Re-add macOS via batch with malformed plist → locks the silent-drop ordering for the batch path.
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
|
|
batchAssociateAppStoreAppsRequest{
|
|
Apps: []fleet.VPPBatchPayload{
|
|
{AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)},
|
|
{AppStoreID: macosAdamID, Platform: fleet.MacOSPlatform, Configuration: asJSONString(`not actually a plist`)},
|
|
},
|
|
}, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name)
|
|
|
|
macMeta, err = s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, macosAdamID, macMeta.AdamID)
|
|
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, batchTeam.ID)
|
|
})
|
|
}
|
|
|
|
// TestManagedAppConfigurationWireFormat asserts the on-the-wire shape of
|
|
// VPPAppStoreApp.Configuration using hand-built request bodies and raw
|
|
// response bytes (no Go struct marshalling on either side).
|
|
func (s *integrationMDMTestSuite) TestManagedAppConfigurationWireFormat() {
|
|
t := s.T()
|
|
s.setSkipWorkerJobs(t)
|
|
ctx := context.Background()
|
|
|
|
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-config-wire-team"})
|
|
require.NoError(t, err)
|
|
s.setVPPTokenForTeam(team.ID)
|
|
|
|
// readBody compacts the pretty-printed JSON so assertions can match unindented wire bytes.
|
|
readBody := func(resp *http.Response) string {
|
|
t.Helper()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
var compact bytes.Buffer
|
|
require.NoError(t, json.Compact(&compact, raw))
|
|
return compact.String()
|
|
}
|
|
|
|
t.Run("vpp ios wire format", func(t *testing.T) {
|
|
// Adam ID "2" is pre-registered as an iOS app by the mock VPP server.
|
|
const iosAdamID = "2"
|
|
reqBody := fmt.Appendf(nil,
|
|
`{"fleet_id":%d,"app_store_id":%q,"platform":"ios","configuration":"<dict><key>K</key><string>v</string></dict>"}`,
|
|
team.ID, iosAdamID)
|
|
resp := s.DoRaw("POST", "/api/latest/fleet/software/app_store_apps", reqBody, http.StatusOK)
|
|
var addResp addAppStoreAppResponse
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp))
|
|
require.NoError(t, resp.Body.Close())
|
|
require.NotZero(t, addResp.TitleID)
|
|
|
|
stored, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, team.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, `<dict><key>K</key><string>v</string></dict>`, string(stored))
|
|
|
|
resp = s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID), nil, http.StatusOK, "fleet_id", fmt.Sprint(team.ID))
|
|
body := readBody(resp)
|
|
require.Contains(t, body, `"configuration":"<dict><key>K</key><string>v</string></dict>"`)
|
|
})
|
|
|
|
t.Run("android wire format", func(t *testing.T) {
|
|
s.enableAndroidMDM(t)
|
|
const androidAdamID = "com.test.wireformat"
|
|
s.androidAPIClient.EnterprisesApplicationsFunc = func(_ context.Context, _, _ string) (*androidmanagement.Application, error) {
|
|
return &androidmanagement.Application{IconUrl: "https://example.com/icon.png", Title: "WireApp"}, nil
|
|
}
|
|
s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(_ context.Context, _ string, policy *androidmanagement.Policy, _ androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) {
|
|
return policy, nil
|
|
}
|
|
|
|
reqBody := fmt.Appendf(nil,
|
|
`{"fleet_id":%d,"app_store_id":%q,"platform":"android","configuration":{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}}`,
|
|
team.ID, androidAdamID)
|
|
resp := s.DoRaw("POST", "/api/latest/fleet/software/app_store_apps", reqBody, http.StatusOK)
|
|
var addResp addAppStoreAppResponse
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp))
|
|
require.NoError(t, resp.Body.Close())
|
|
require.NotZero(t, addResp.TitleID)
|
|
|
|
stored, err := s.ds.GetAndroidAppConfiguration(ctxdb.RequirePrimary(ctx, true), androidAdamID, team.ID)
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}`, string(stored))
|
|
|
|
resp = s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID), nil, http.StatusOK, "fleet_id", fmt.Sprint(team.ID))
|
|
body := readBody(resp)
|
|
require.Contains(t, body, `"configuration":{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}`)
|
|
})
|
|
}
|
|
|
|
// TestVPPManagedConfigurationOnInstallCommand asserts the InstallApplication
|
|
// MDM command bytes for the VPP managed-configuration paths.
|
|
func (s *integrationMDMTestSuite) TestVPPManagedConfigurationOnInstallCommand() {
|
|
t := s.T()
|
|
s.setSkipWorkerJobs(t)
|
|
ctx := context.Background()
|
|
|
|
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-managedcfg-install-team"})
|
|
require.NoError(t, err)
|
|
s.setVPPTokenForTeam(team.ID)
|
|
s.registerResetVPPProxyData(t)
|
|
|
|
asJSONString := func(s string) json.RawMessage {
|
|
b, err := json.Marshal(s)
|
|
require.NoError(t, err)
|
|
return json.RawMessage(b)
|
|
}
|
|
|
|
titleIDFor := func(adamID string, platform fleet.InstallableDevicePlatform) uint {
|
|
var id uint
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &id,
|
|
`SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, adamID, platform)
|
|
})
|
|
require.NotZero(t, id, "title_id for adam=%s platform=%s", adamID, platform)
|
|
return id
|
|
}
|
|
|
|
// installAndCaptureCmd triggers an install and returns the InstallApplication bytes,
|
|
// then completes the verification so the host is ready for another install.
|
|
installAndCaptureCmd := func(t *testing.T, host *fleet.Host, dev *mdmtest.TestAppleMDMClient, titleID uint, installed fleet.Software) []byte {
|
|
t.Helper()
|
|
installed.Installed = true
|
|
// Drain any pending verification commands from prior installs.
|
|
for {
|
|
cmd, err := dev.Idle()
|
|
require.NoError(t, err)
|
|
if cmd == nil {
|
|
break
|
|
}
|
|
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType,
|
|
"unexpected pending command %q while draining verifications", cmd.Command.RequestType)
|
|
_, err = dev.AcknowledgeInstalledApplicationList(dev.UUID, cmd.CommandUUID,
|
|
[]fleet.Software{installed})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
var installResp installSoftwareResponse
|
|
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID),
|
|
&installSoftwareRequest{}, http.StatusAccepted, &installResp)
|
|
|
|
s.awaitRunAppleMDMWorkerSchedule()
|
|
s.runWorker()
|
|
cmd, err := dev.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd, "expected an MDM command after install trigger but device went idle")
|
|
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
|
|
raw := append([]byte(nil), cmd.Raw...)
|
|
_, err = dev.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
|
|
s.runWorker()
|
|
cmd, err = dev.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd, "expected an InstalledApplicationList verification command")
|
|
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
|
|
installed.Installed = true
|
|
_, err = dev.AcknowledgeInstalledApplicationList(dev.UUID, cmd.CommandUUID,
|
|
[]fleet.Software{installed})
|
|
require.NoError(t, err)
|
|
return raw
|
|
}
|
|
|
|
// Adam ID "2" is registered for iOS + iPadOS + macOS in the mock proxy.
|
|
const adamMulti = "2"
|
|
// Adam ID "1" is macOS-only.
|
|
const adamMac = "1"
|
|
|
|
iosHost, iosDev := s.createAppleMobileHostThenEnrollMDM("ios")
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDev.SerialNumber)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{iosHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
|
|
// App-2 metadata as the mock proxy reports it for ios / ipados.
|
|
app2Installed := fleet.Software{Name: "App 2", BundleIdentifier: "b-2", Version: "2.0.0"}
|
|
|
|
t.Run("iOS install carries Configuration and resolves $FLEET_VAR_HOST_UUID", func(t *testing.T) {
|
|
plistXML := `<dict><key>K</key><string>v</string><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`
|
|
var addResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID, AppStoreID: adamMulti, Platform: fleet.IOSPlatform,
|
|
Configuration: asJSONString(plistXML),
|
|
}, http.StatusOK, &addResp)
|
|
|
|
raw := string(installAndCaptureCmd(t, iosHost, iosDev, titleIDFor(adamMulti, fleet.IOSPlatform), app2Installed))
|
|
require.Contains(t, raw, "<key>Configuration</key>",
|
|
"InstallApplication should include Configuration dict for iOS")
|
|
require.Contains(t, raw, "<key>K</key>")
|
|
require.Contains(t, raw, "<string>v</string>")
|
|
require.NotContains(t, raw, "$FLEET_VAR_HOST_UUID",
|
|
"Fleet variable must be resolved before sending to device")
|
|
require.Contains(t, raw, fmt.Sprintf("<string>%s</string>", iosHost.UUID),
|
|
"Resolved value should be the iOS host's own UUID")
|
|
})
|
|
|
|
t.Run("clearing configuration drops Configuration from the next install", func(t *testing.T) {
|
|
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
|
|
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: json.RawMessage(`null`)},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0)
|
|
|
|
_, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, adamMulti, team.ID)
|
|
require.True(t, fleet.IsNotFound(err), "expected config row deleted")
|
|
|
|
raw := string(installAndCaptureCmd(t, iosHost, iosDev, titleID, app2Installed))
|
|
require.NotContains(t, raw, "<key>Configuration</key>",
|
|
"InstallApplication should not include Configuration after clearing the stored config")
|
|
})
|
|
|
|
t.Run("second iOS host gets its own UUID substituted (per-host resolution)", func(t *testing.T) {
|
|
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(`<dict><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`),
|
|
},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
ios2Host, ios2Dev := s.createAppleMobileHostThenEnrollMDM("ios")
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ios2Dev.SerialNumber)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{ios2Host.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
require.NotEqual(t, iosHost.UUID, ios2Host.UUID, "second host needs a distinct UUID for this test to be meaningful")
|
|
|
|
raw := string(installAndCaptureCmd(t, ios2Host, ios2Dev, titleID, app2Installed))
|
|
require.Contains(t, raw, fmt.Sprintf("<string>%s</string>", ios2Host.UUID),
|
|
"second host's command should carry its own UUID")
|
|
require.NotContains(t, raw, fmt.Sprintf("<string>%s</string>", iosHost.UUID),
|
|
"second host's command must not carry the first host's UUID")
|
|
})
|
|
|
|
t.Run("macOS install drops Configuration even when one was sent on add", func(t *testing.T) {
|
|
plistXML := `<dict><key>K</key><string>v</string></dict>`
|
|
var addResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID, AppStoreID: adamMac, Platform: fleet.MacOSPlatform,
|
|
Configuration: asJSONString(plistXML),
|
|
}, http.StatusOK, &addResp)
|
|
|
|
// macOS install path requires fleetd enrollment.
|
|
macHost, macDev := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
|
setOrbitEnrollment(t, macHost, s.ds)
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, macHost.HardwareSerial)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{macHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
// Drain the InstallFleetd command queued by enrollment.
|
|
s.awaitRunAppleMDMWorkerSchedule()
|
|
s.runWorker()
|
|
checkInstallFleetdCommandSent(t, macDev, true)
|
|
|
|
raw := string(installAndCaptureCmd(t, macHost, macDev,
|
|
titleIDFor(adamMac, fleet.MacOSPlatform),
|
|
fleet.Software{Name: "App 1", BundleIdentifier: "a-1", Version: "1.0.0"}))
|
|
require.NotContains(t, raw, "<key>Configuration</key>",
|
|
"macOS InstallApplication must not carry Configuration even if one was POSTed")
|
|
})
|
|
|
|
t.Run("same adam_id on iOS and iPadOS keep configs isolated", func(t *testing.T) {
|
|
const iosCfg = `<dict><key>side</key><string>ios</string></dict>`
|
|
const ipadCfg = `<dict><key>side</key><string>ipados</string></dict>`
|
|
|
|
iosTitle := titleIDFor(adamMulti, fleet.IOSPlatform)
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", iosTitle),
|
|
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(iosCfg)},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
var addResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID, AppStoreID: adamMulti, Platform: fleet.IPadOSPlatform,
|
|
Configuration: asJSONString(ipadCfg),
|
|
}, http.StatusOK, &addResp)
|
|
|
|
storedIOS, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true),
|
|
fleet.IOSPlatform, adamMulti, team.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, iosCfg, string(storedIOS))
|
|
storedIPad, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true),
|
|
fleet.IPadOSPlatform, adamMulti, team.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ipadCfg, string(storedIPad))
|
|
require.NotEqual(t, string(storedIOS), string(storedIPad))
|
|
|
|
ipadHost, ipadDev := s.createAppleMobileHostThenEnrollMDM("ipados")
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ipadDev.SerialNumber)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{ipadHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
|
|
raw := string(installAndCaptureCmd(t, ipadHost, ipadDev,
|
|
titleIDFor(adamMulti, fleet.IPadOSPlatform), app2Installed))
|
|
require.Contains(t, raw, "<string>ipados</string>",
|
|
"iPadOS install must use the iPadOS-scoped config")
|
|
require.NotContains(t, raw, "<string>ios</string>",
|
|
"iPadOS install must not leak the iOS-scoped config bytes")
|
|
})
|
|
|
|
t.Run("updating configuration → next install carries the new bytes", func(t *testing.T) {
|
|
// Every install path re-reads the stored config at enqueue time
|
|
// (nanoEnqueueVPPInstall), so this also exercises the auto-update freshness path.
|
|
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
|
|
const updatedCfg = `<dict><key>K</key><string>updated</string></dict>`
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
|
|
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(updatedCfg)},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
raw := string(installAndCaptureCmd(t, iosHost, iosDev, titleID, app2Installed))
|
|
require.Contains(t, raw, "<key>Configuration</key>",
|
|
"updated install should still carry a Configuration dict")
|
|
require.Contains(t, raw, "<string>updated</string>",
|
|
"updated install must carry the latest stored config bytes")
|
|
|
|
// Updating to a config that references an IDP variable the host hasn't
|
|
// been linked to → the install is RECORDED AS FAILED (visible in the
|
|
// activity feed + Install Details modal) instead of being rejected with
|
|
// a 400. A pre-flight substitution check in InstallVPPAppPostValidation
|
|
// catches this before reserving a license or enqueuing a command.
|
|
// Regression for #45851 / #45854.
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(`<dict><key>Email</key><string>$FLEET_VAR_HOST_END_USER_EMAIL_IDP</string></dict>`),
|
|
},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
var uaBefore int
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &uaBefore,
|
|
`SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?`, iosHost.ID)
|
|
})
|
|
|
|
s.DoJSON("POST",
|
|
fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", iosHost.ID, titleID),
|
|
&installSoftwareRequest{}, http.StatusAccepted, &installSoftwareResponse{})
|
|
|
|
// A failed install row is recorded (verification_failed_at set).
|
|
var failedCount int
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &failedCount,
|
|
`SELECT COUNT(*) FROM host_vpp_software_installs WHERE host_id = ? AND verification_failed_at IS NOT NULL`, iosHost.ID)
|
|
})
|
|
require.Equal(t, 1, failedCount, "a failed VPP install row must be recorded")
|
|
|
|
// No zombie upcoming activity and no enqueued MDM command.
|
|
var uaAfter, nanoCount int
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
if err := sqlx.GetContext(ctx, q, &uaAfter,
|
|
`SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?`, iosHost.ID); err != nil {
|
|
return err
|
|
}
|
|
return sqlx.GetContext(ctx, q, &nanoCount,
|
|
`SELECT COUNT(*) FROM nano_commands nc
|
|
JOIN host_vpp_software_installs hvsi ON hvsi.command_uuid = nc.command_uuid
|
|
WHERE hvsi.host_id = ? AND hvsi.verification_failed_at IS NOT NULL`, iosHost.ID)
|
|
})
|
|
require.Equal(t, uaBefore, uaAfter, "failed install must not leave a zombie upcoming_activity row")
|
|
require.Zero(t, nanoCount, "failed install must not enqueue an MDM command")
|
|
|
|
// The failure is visible as a failed installed_app_store_app activity
|
|
// carrying the unresolvable variable as the reason.
|
|
var listActs listActivitiesResponse
|
|
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &listActs,
|
|
"order_key", "a.id", "order_direction", "desc", "per_page", "10")
|
|
var found bool
|
|
for _, act := range listActs.Activities {
|
|
if act.Type == (fleet.ActivityInstalledAppStoreApp{}).ActivityName() {
|
|
require.NotNil(t, act.Details)
|
|
require.Contains(t, string(*act.Details), string(fleet.SoftwareInstallFailed))
|
|
require.Contains(t, string(*act.Details), "$FLEET_VAR_HOST_END_USER_EMAIL_IDP")
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, found, "a failed installed_app_store_app activity must be in the feed")
|
|
|
|
// Regression: the per-team/per-app status summary (vpp.go
|
|
// GetSummaryHostVPPAppInstalls) and the host software list
|
|
// status=failed_install filter (software_installers.go
|
|
// vppHostSoftwareInstallJoin) must both count Fleet-side pre-flight
|
|
// failures. Before the SQL fix, the WHERE `ncr.id IS NOT NULL …`
|
|
// clause dropped these rows entirely (no nano_command_results is
|
|
// written when no MDM command is enqueued), so the install row
|
|
// existed in host_vpp_software_installs with verification_failed_at
|
|
// set but was invisible to both surfaces.
|
|
var titleSummary getSoftwareTitleResponse
|
|
s.DoJSON("GET",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID),
|
|
&getSoftwareTitleRequest{ID: titleID, TeamID: &team.ID},
|
|
http.StatusOK, &titleSummary, "fleet_id", fmt.Sprint(team.ID))
|
|
require.NotNil(t, titleSummary.SoftwareTitle.AppStoreApp)
|
|
require.NotNil(t, titleSummary.SoftwareTitle.AppStoreApp.Status)
|
|
require.GreaterOrEqual(t, titleSummary.SoftwareTitle.AppStoreApp.Status.Failed, uint(1),
|
|
"per-team/per-app failed-install summary must count the pre-flight failure")
|
|
|
|
var hostSw getHostSoftwareResponse
|
|
s.DoJSON("GET",
|
|
fmt.Sprintf("/api/latest/fleet/hosts/%d/software", iosHost.ID),
|
|
nil, http.StatusOK, &hostSw, "status", "failed_install")
|
|
var foundInList bool
|
|
for _, sw := range hostSw.Software {
|
|
if sw.ID == titleID {
|
|
foundInList = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundInList,
|
|
"host software list filtered by status=failed_install must include the pre-flight-failed title")
|
|
})
|
|
|
|
t.Run("self-service install carries Configuration with $FLEET_VAR_HOST_UUID resolved", func(t *testing.T) {
|
|
ssHost, ssDev := s.createAppleMobileHostThenEnrollMDM("ios")
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ssDev.SerialNumber)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{ssHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
|
|
// Self-service device endpoint requires cert auth.
|
|
const certSerial = uint64(987654321)
|
|
s.addHostIdentityCertificate(ssHost.UUID, certSerial)
|
|
headers := map[string]string{
|
|
"X-Client-Cert-Serial": fmt.Sprintf("%d", certSerial),
|
|
}
|
|
|
|
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID, SelfService: new(true),
|
|
Configuration: asJSONString(`<dict><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`),
|
|
},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
s.DoRawWithHeaders("POST",
|
|
fmt.Sprintf("/api/latest/fleet/device/%s/software/install/%d", ssHost.UUID, titleID),
|
|
nil, http.StatusAccepted, headers)
|
|
|
|
s.awaitRunAppleMDMWorkerSchedule()
|
|
s.runWorker()
|
|
cmd, err := ssDev.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
|
|
raw := string(cmd.Raw)
|
|
require.Contains(t, raw, "<key>Configuration</key>",
|
|
"self-service iOS install should still include Configuration")
|
|
require.Contains(t, raw, fmt.Sprintf("<string>%s</string>", ssHost.UUID),
|
|
"self-service install must resolve $FLEET_VAR_HOST_UUID to this host's UUID")
|
|
_, err = ssDev.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("auto-update install carries the managed Configuration", func(t *testing.T) {
|
|
// adam "3" is unused by prior subtests; override its proxy metadata
|
|
// to declare iOS at v1.0.0 (default proxy data has it as iPadOS-only).
|
|
const cfgAdamID = "3"
|
|
s.appleVPPProxySrvData[cfgAdamID] = `{"id": "3", "attributes": {"name": "Config App", "platformAttributes": {"ios": {"bundleId": "app-cfg", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`
|
|
|
|
cfgHost, cfgDev := s.createAppleMobileHostThenEnrollMDM("ios")
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, cfgDev.SerialNumber)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{cfgHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
|
|
var addResp addAppStoreAppResponse
|
|
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
|
TeamID: &team.ID, AppStoreID: cfgAdamID, Platform: fleet.IOSPlatform,
|
|
Configuration: json.RawMessage(`"<dict><key>auto</key><string>update</string></dict>"`),
|
|
}, http.StatusOK, &addResp)
|
|
appTitleID := addResp.TitleID
|
|
require.NotZero(t, appTitleID)
|
|
|
|
// First install.
|
|
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", cfgHost.ID, appTitleID),
|
|
&installSoftwareRequest{}, http.StatusAccepted, &installSoftwareResponse{})
|
|
s.awaitRunAppleMDMWorkerSchedule()
|
|
s.runWorker()
|
|
cmd, err := cfgDev.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
|
|
require.Contains(t, string(cmd.Raw), "<key>Configuration</key>")
|
|
require.Contains(t, string(cmd.Raw), "<string>update</string>")
|
|
_, err = cfgDev.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
s.runWorker()
|
|
cmd, err = cfgDev.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
|
|
_, err = cfgDev.AcknowledgeInstalledApplicationList(cfgDev.UUID, cmd.CommandUUID, []fleet.Software{
|
|
{Name: "Config App", BundleIdentifier: "app-cfg", Version: "1.0.0", Installed: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// handleRefetch processes the three commands a refetch queues:
|
|
// InstalledApplicationList, CertificateList, DeviceInformation.
|
|
handleRefetch := func(software []fleet.Software) {
|
|
s.runWorker()
|
|
c, err := cfgDev.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "InstalledApplicationList", c.Command.RequestType)
|
|
_, err = cfgDev.AcknowledgeInstalledApplicationList(cfgDev.UUID, c.CommandUUID, software)
|
|
require.NoError(t, err)
|
|
c, err = cfgDev.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "CertificateList", c.Command.RequestType)
|
|
_, err = cfgDev.AcknowledgeCertificateList(cfgDev.UUID, c.CommandUUID, nil)
|
|
require.NoError(t, err)
|
|
c, err = cfgDev.Idle()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "DeviceInformation", c.Command.RequestType)
|
|
_, err = cfgDev.AcknowledgeDeviceInformation(cfgDev.UUID, c.CommandUUID, "iPhone 17", "iPhone", "America/Los_Angeles")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// First refetch populates the host's timezone (DeviceInformation is sent last).
|
|
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", cfgHost.ID), nil, http.StatusOK)
|
|
handleRefetch([]fleet.Software{
|
|
{Name: "Config App", BundleIdentifier: "app-cfg", Version: "1.0.0", Installed: true},
|
|
})
|
|
|
|
// Enable auto-update inside the current Los Angeles window.
|
|
nowInLA, err := getCurrentLocalTimeInHostTimeZone(ctx, "America/Los_Angeles")
|
|
require.NoError(t, err)
|
|
startHHMM := nowInLA.Add(-30 * time.Minute).Format("15:04")
|
|
endHHMM := nowInLA.Add(30 * time.Minute).Format("15:04")
|
|
s.DoJSON("PATCH", fmt.Sprintf("/api/v1/fleet/software/titles/%d/app_store_app", appTitleID),
|
|
updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
AutoUpdateEnabled: new(true),
|
|
AutoUpdateStartTime: &startHHMM,
|
|
AutoUpdateEndTime: &endHHMM,
|
|
}, http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
// Bump latest version in proxy data, refresh, and bypass the 1-hour install filter.
|
|
s.appleVPPProxySrvData[cfgAdamID] = `{"id": "3", "attributes": {"name": "Config App", "platformAttributes": {"ios": {"bundleId": "app-cfg", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`
|
|
require.NoError(t, vpp.RefreshVersions(ctx, s.ds, apple_apps.StubbedConfig()))
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE host_vpp_software_installs SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE host_id = ?`, cfgHost.ID)
|
|
return err
|
|
})
|
|
|
|
// Refetch with the host still on v1.0.0 → auto-update kicks in.
|
|
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", cfgHost.ID), nil, http.StatusOK)
|
|
handleRefetch([]fleet.Software{
|
|
{Name: "Config App", BundleIdentifier: "app-cfg", Version: "1.0.0", Installed: true},
|
|
})
|
|
|
|
s.runWorker()
|
|
cmd, err = cfgDev.Idle()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cmd, "expected an auto-update InstallApplication command")
|
|
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
|
|
require.Contains(t, string(cmd.Raw), "<key>Configuration</key>",
|
|
"scheduled auto-update InstallApplication must carry Configuration")
|
|
require.Contains(t, string(cmd.Raw), "<string>update</string>",
|
|
"scheduled auto-update must use the latest stored config bytes")
|
|
|
|
isAuto, err := s.ds.IsAutoUpdateVPPInstall(ctx, cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
require.True(t, isAuto, "command must be recorded with from_auto_update=true")
|
|
|
|
_, err = cfgDev.Acknowledge(cmd.CommandUUID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("canceling a pending VPP install releases the reserved VPP license seat", func(t *testing.T) {
|
|
// Regression for #45851 / #45854 (license-leak follow-up to Jonathan's
|
|
// note about activated VPP install activities leaking licenses).
|
|
//
|
|
// A fresh install on a not-yet-licensed device reserves a seat via
|
|
// AssociateAssets. If the admin then cancels the upcoming activity
|
|
// before it reaches the device, Fleet must release the seat via
|
|
// DisassociateAssets — otherwise the seat leaks.
|
|
cancelHost, cancelDev := s.createAppleMobileHostThenEnrollMDM("ios")
|
|
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, cancelDev.SerialNumber)
|
|
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
|
&addHostsToTeamRequest{HostIDs: []uint{cancelHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
|
|
|
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
|
|
|
|
// Reset the managed config to a resolvable one so the install proceeds
|
|
// past the pre-flight check and actually reserves a seat.
|
|
s.DoJSON("PATCH",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
|
|
&updateAppStoreAppRequest{
|
|
TeamID: &team.ID,
|
|
Configuration: asJSONString(`<dict><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`),
|
|
},
|
|
http.StatusOK, &updateAppStoreAppResponse{})
|
|
|
|
// Snapshot the disassociate-call count so we can assert exactly one new
|
|
// release happens on cancel.
|
|
disassocBefore := len(s.appleVPPConfigSrvConfig.Disassociations)
|
|
|
|
s.DoJSON("POST",
|
|
fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", cancelHost.ID, titleID),
|
|
&installSoftwareRequest{}, http.StatusAccepted, &installSoftwareResponse{})
|
|
|
|
// Pull the activated install's command_uuid (== upcoming_activities.execution_id).
|
|
var row struct {
|
|
CommandUUID string `db:"command_uuid"`
|
|
AssociatedEventID string `db:"associated_event_id"`
|
|
}
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &row,
|
|
`SELECT command_uuid, COALESCE(associated_event_id, '') AS associated_event_id
|
|
FROM host_vpp_software_installs WHERE host_id = ? AND adam_id = ?`,
|
|
cancelHost.ID, adamMulti)
|
|
})
|
|
// Sanity: this install was the one that reserved a seat.
|
|
require.NotEmpty(t, row.AssociatedEventID, "test setup: this install should have reserved a license")
|
|
commandUUID := row.CommandUUID
|
|
|
|
// Cancel the upcoming activity (DELETE /hosts/{id}/activities/upcoming/{activity_id}).
|
|
s.Do("DELETE",
|
|
fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", cancelHost.ID, commandUUID),
|
|
nil, http.StatusNoContent)
|
|
|
|
// The mock VPP server must have received exactly one disassociate for
|
|
// this adam_id + serial.
|
|
require.Len(t, s.appleVPPConfigSrvConfig.Disassociations, disassocBefore+1,
|
|
"cancel must trigger exactly one DisassociateAssets call")
|
|
got := s.appleVPPConfigSrvConfig.Disassociations[disassocBefore]
|
|
require.Len(t, got.Assets, 1)
|
|
require.Equal(t, adamMulti, got.Assets[0].AdamID)
|
|
require.Equal(t, []string{cancelDev.SerialNumber}, got.SerialNumbers)
|
|
require.Empty(t, got.ClientUserIds, "device-enrolled host must disassociate by serial, not clientUserId")
|
|
})
|
|
}
|