fleet/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
Martin Angers 2a8803884b
DDMV: Support Fleet variables in DDM (#43222)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43047 

# Checklist for submitter

- [x] 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.

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually
See
https://github.com/fleetdm/fleet/issues/42960#issuecomment-4244206563
and subsequent comments.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Apple DDM declarations support a vetted subset of Fleet variables with
per-host substitution; premium license required. Declaration tokens and
resend behavior now reflect variable changes; unresolved host
substitutions mark that host’s declaration as failed.

* **Bug Fixes**
* Clearer errors for unsupported or license-restricted Fleet variables
and more consistent DDM resend/update semantics when variables change.

* **Tests**
* Added extensive unit and integration tests covering Fleet variable
validation, substitution, token changes, resends, and failure states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-20 09:14:52 -04:00

871 lines
30 KiB
Go

package testing_utils
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/docker/go-units"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/cached_mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/fleetdm/fleet/v4/server/mock"
mock2 "github.com/fleetdm/fleet/v4/server/mock/mdm"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
const (
teamName = "Team Test"
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
)
// RunServerWithMockedDS runs the fleet server with several mocked DS methods.
//
// NOTE: Assumes the current session is always from the admin user (see ds.SessionByKeyFunc below).
func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*httptest.Server, *mock.Store) {
ds := new(mock.Store)
var users []*fleet.User
var admin *fleet.User
ds.NewUserFunc = func(ctx context.Context, user *fleet.User) (*fleet.User, error) {
if user.GlobalRole != nil && *user.GlobalRole == fleet.RoleAdmin {
admin = user
}
users = append(users, user)
return user, nil
}
ds.SessionByKeyFunc = func(ctx context.Context, key string) (*fleet.Session, error) {
return &fleet.Session{
CreateTimestamp: fleet.CreateTimestamp{CreatedAt: time.Now()},
ID: 1,
AccessedAt: time.Now(),
UserID: admin.ID,
Key: key,
}, nil
}
ds.MarkSessionAccessedFunc = func(ctx context.Context, session *fleet.Session) error {
return nil
}
ds.UserByIDFunc = func(ctx context.Context, id uint) (*fleet.User, error) {
return admin, nil
}
ds.ListUsersFunc = func(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) {
return users, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewGlobalPolicyFunc = func(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) {
return &fleet.Policy{
PolicyData: fleet.PolicyData{
Name: args.Name,
Query: args.Query,
Critical: args.Critical,
Platform: args.Platform,
Description: args.Description,
Resolution: &args.Resolution,
AuthorID: authorID,
Type: "dynamic",
},
}, nil
}
ds.NewScriptFunc = func(ctx context.Context, script *fleet.Script) (*fleet.Script, error) {
if !strings.HasPrefix(script.ScriptContents, "#!/") {
return nil, errors.New("script not uploaded properly")
}
return &fleet.Script{
ID: 1,
}, nil
}
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) { return nil, nil }
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
require.NoError(t, err)
ds.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
return map[fleet.MDMAssetName]string{
fleet.MDMAssetABMCert: "abmcert",
fleet.MDMAssetABMKey: "abmkey",
fleet.MDMAssetABMTokenDeprecated: "abmtoken",
fleet.MDMAssetAPNSCert: "apnscert",
fleet.MDMAssetAPNSKey: "apnskey",
fleet.MDMAssetCACert: "scepcert",
fleet.MDMAssetCAKey: "scepkey",
}, nil
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},
fleet.MDMAssetABMTokenDeprecated: {Name: fleet.MDMAssetABMTokenDeprecated, Value: tokenBytes},
fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert},
fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey},
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM},
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM},
}, nil
}
ds.ApplyYaraRulesFunc = func(context.Context, []fleet.YaraRule) error {
return nil
}
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error {
return nil
}
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
return nil, nil
}
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
return nil, nil
}
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) {
return &fleet.GroupedCertificateAuthorities{}, nil
}
ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) {
return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil
}
ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) {
return []fleet.VPPAppResponse{}, nil
}
ds.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) {
return nil, nil
}
ds.GetHostRecoveryLockPasswordStatusFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMRecoveryLockPassword, error) {
return nil, nil
}
ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
return &fleet.TeamMDM{}, nil
}
var cachedDS fleet.Datastore
if len(opts) > 0 && opts[0].NoCacheDatastore {
cachedDS = ds
} else {
cachedDS = cached_mysql.New(ds)
}
_, server := service.RunServerForTestsWithDS(t, cachedDS, opts...)
os.Setenv("FLEET_SERVER_ADDRESS", server.URL)
return server, ds
}
func getPathRelative(relativePath string) string {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
panic("failed to get runtime caller info")
}
sourceDir := filepath.Dir(currentFile)
return filepath.Join(sourceDir, relativePath)
}
func ServeMDMBootstrapPackage(t *testing.T, pkgPath, pkgName string) (*httptest.Server, int) {
pkgBytes, err := os.ReadFile(pkgPath)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(len(pkgBytes)))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, pkgName))
if n, err := w.Write(pkgBytes); err != nil {
require.NoError(t, err)
require.Equal(t, len(pkgBytes), n)
}
}))
t.Cleanup(srv.Close)
return srv, len(pkgBytes)
}
func StartSoftwareInstallerServer(t *testing.T) {
// load the ruby installer to use as base bytes to repeat for the "too large" case
b, err := os.ReadFile(getPathRelative("../../../../server/service/testdata/software-installers/ruby.deb"))
require.NoError(t, err)
// get the base dir of all installers
baseDir := getPathRelative("../../../../server/service/testdata/software-installers/")
// start the web server that will serve the installer
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "notfound"):
w.WriteHeader(http.StatusNotFound)
return
case strings.HasSuffix(r.URL.Path, ".txt"):
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(`a simple text file`))
return
case strings.Contains(r.URL.Path, "toolarge"):
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
var sz int
for sz < 513*units.MiB {
n, _ := w.Write(b)
sz += n
}
case strings.Contains(r.URL.Path, "other.deb"):
// serve same content as ruby.deb
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
_, _ = w.Write(b)
case strings.HasSuffix(r.URL.Path, ".pkg"):
pkgDir := getPathRelative("../testdata/gitops/lib/")
http.ServeFile(w, r, filepath.Join(pkgDir, filepath.Base(r.URL.Path)))
default:
http.ServeFile(w, r, filepath.Join(baseDir, filepath.Base(r.URL.Path)))
}
},
),
)
t.Cleanup(srv.Close)
t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL)
}
func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, map[string]**fleet.Team) {
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, getPathRelative("../../../../server/service/testdata"))
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
_, ds := RunServerWithMockedDS(
t, &service.TestServerOpts{
MDMStorage: new(mock2.MDMAppleStore),
MDMPusher: MockPusher{},
FleetConfig: &fleetCfg,
License: license,
NoCacheDatastore: true,
KeyValueStore: NewMemKeyValueStore(),
},
)
// Mock appConfig
savedAppConfig := &fleet.AppConfig{
MDM: fleet.MDM{
EnabledAndConfigured: true,
AndroidEnabledAndConfigured: true,
},
}
AddLabelMocks(ds)
// Add DefaultTeamConfig mocks for enterprise features
ds.DefaultTeamConfigFunc = func(ctx context.Context) (*fleet.TeamConfig, error) {
return &fleet.TeamConfig{}, nil
}
ds.SaveDefaultTeamConfigFunc = func(ctx context.Context, config *fleet.TeamConfig) error {
return nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
appConfigCopy := *savedAppConfig
return &appConfigCopy, nil
}
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
appConfigCopy := *config
savedAppConfig = &appConfigCopy
return nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) {
return false, nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.ListSoftwareAutoUpdateSchedulesFunc = func(ctx context.Context, teamID uint, source string, optionalFilter ...fleet.SoftwareAutoUpdateScheduleFilter) ([]fleet.SoftwareAutoUpdateSchedule, error) {
return []fleet.SoftwareAutoUpdateSchedule{}, nil
}
savedTeams := map[string]**fleet.Team{}
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
return nil
}
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
return nil
}
ds.ApplyQueriesFunc = func(
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
) error {
return nil
}
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
return []fleet.ScriptResponse{}, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(
ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string,
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.SetOrUpdateMDMWindowsConfigProfileFunc = func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error {
return nil
}
ds.DeleteMDMWindowsConfigProfileByTeamAndNameFunc = func(ctx context.Context, teamID *uint, profileName string) error {
return nil
}
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
return nil
}
ds.GetMDMAppleBootstrapPackageMetaFunc = func(ctx context.Context, teamID uint) (*fleet.MDMAppleBootstrapPackage, error) {
return &fleet.MDMAppleBootstrapPackage{}, nil
}
ds.DeleteMDMAppleBootstrapPackageFunc = func(ctx context.Context, teamID uint) error {
return nil
}
ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error {
return nil
}
ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) {
return nil, nil
}
ds.DeleteMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) error {
return nil
}
ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error) {
return true, nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
require.ElementsMatch(t, names, []string{fleet.BuiltinLabelMacOS14Plus})
return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
if savedTeams != nil {
var result []*fleet.Team
for _, t := range savedTeams {
result = append(result, *t)
}
return result, nil
}
return nil, nil
}
ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) {
summary := make([]*fleet.TeamSummary, 0, len(savedTeams))
for _, team := range savedTeams {
summary = append(summary, &fleet.TeamSummary{
ID: (*team).ID,
Name: (*team).Name,
Description: (*team).Description,
})
}
return summary, nil
}
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) {
return nil, 0, 0, nil, nil
}
ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, p fleet.MDMAppleConfigProfile, vars []fleet.FleetVarName) (*fleet.MDMAppleConfigProfile, error) {
return nil, nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
job.ID = 1
return job, nil
}
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
team.ID = uint(len(savedTeams) + 1) //nolint:gosec // dismiss G115
savedTeams[team.Name] = &team
return team, nil
}
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
return nil, &notFoundError{}
}
ds.TeamWithExtrasFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
for _, tm := range savedTeams {
if (*tm).ID == tid {
return *tm, nil
}
}
return nil, &notFoundError{}
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
if tid == 0 {
return &fleet.TeamLite{}, nil
}
for _, tm := range savedTeams {
if (*tm).ID == tid {
teamToCopy := *tm
return &fleet.TeamLite{
ID: teamToCopy.ID,
Filename: teamToCopy.Filename,
CreatedAt: teamToCopy.CreatedAt,
Name: teamToCopy.Name,
Description: teamToCopy.Description,
Config: teamToCopy.Config.ToLite(),
}, nil
}
}
return nil, &notFoundError{}
}
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
for _, tm := range savedTeams {
if (*tm).Name == name {
return *tm, nil
}
}
return nil, &notFoundError{}
}
ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) {
for _, tm := range savedTeams {
if (*tm).Filename != nil && *(*tm).Filename == filename {
return *tm, nil
}
}
return nil, &notFoundError{}
}
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
savedTeams[team.Name] = &team
return team, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (
*fleet.MDMAppleDeclaration, error,
) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) {
return nil, nil
}
ds.InsertVPPTokenFunc = func(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{}, nil
}
ds.GetVPPTokenFunc = func(ctx context.Context, tokenID uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{}, err
}
ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{}, nil
}
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return nil, nil
}
ds.UpdateVPPTokenTeamsFunc = func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) {
return &fleet.VPPTokenDB{}, nil
}
ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, nil
}
ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions,
tmFilter fleet.TeamFilter,
) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
return nil, 0, nil, nil
}
ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error {
return nil
}
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{}, nil
}
ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
return []*fleet.ABMToken{}, nil
}
ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error {
return nil
}
ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error {
return nil
}
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) {
return document, nil, nil
}
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return nil, &notFoundError{} // No existing EULA
}
ds.BatchApplyCertificateAuthoritiesFunc = func(ctx context.Context, ops fleet.CertificateAuthoritiesBatchOperations) error {
return nil
}
ds.DeleteIconsAssociatedWithTitlesWithoutInstallersFunc = func(ctx context.Context, teamID uint) error {
return nil
}
ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) {
return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil
}
ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) {
return nil, nil
}
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
t.Setenv("TEST_TEAM_NAME", teamName)
t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName)
return ds, &savedAppConfig, savedTeams
}
type AppleVPPConfigSrvConf struct {
Assets []vpp.Asset
SerialNumbers []string
}
func StartVPPApplyServer(t *testing.T, config *AppleVPPConfigSrvConf) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "associate") {
var associations vpp.AssociateAssetsRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&associations); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if len(associations.Assets) == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
res := vpp.ErrorResponse{
ErrorNumber: 9718,
ErrorMessage: "This request doesn't contain an asset, which is a required argument. Change the request to provide an asset.",
}
if err := json.NewEncoder(w).Encode(res); err != nil {
panic(err)
}
return
}
if len(associations.SerialNumbers) == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
res := vpp.ErrorResponse{
ErrorNumber: 9719,
ErrorMessage: "Either clientUserIds or serialNumbers are required arguments. Change the request to provide assignable users and devices.",
}
if err := json.NewEncoder(w).Encode(res); err != nil {
panic(err)
}
return
}
var badAssets []vpp.Asset
for _, reqAsset := range associations.Assets {
var found bool
for _, goodAsset := range config.Assets {
if reqAsset == goodAsset {
found = true
}
}
if !found {
badAssets = append(badAssets, reqAsset)
}
}
var badSerials []string
for _, reqSerial := range associations.SerialNumbers {
var found bool
for _, goodSerial := range config.SerialNumbers {
if reqSerial == goodSerial {
found = true
}
}
if !found {
badSerials = append(badSerials, reqSerial)
}
}
if len(badAssets) != 0 || len(badSerials) != 0 {
errMsg := "error associating assets."
if len(badAssets) > 0 {
var badAdamIds []string
for _, asset := range badAssets {
badAdamIds = append(badAdamIds, asset.AdamID)
}
errMsg += fmt.Sprintf(" assets don't exist on account: %s.", strings.Join(badAdamIds, ", "))
}
if len(badSerials) > 0 {
errMsg += fmt.Sprintf(" bad serials: %s.", strings.Join(badSerials, ", "))
}
res := vpp.ErrorResponse{
ErrorInfo: vpp.ResponseErrorInfo{
Assets: badAssets,
ClientUserIds: []string{"something"},
SerialNumbers: badSerials,
},
// Not sure what error should be returned on each
// error type
ErrorNumber: 1,
ErrorMessage: errMsg,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(res); err != nil {
panic(err)
}
}
return
}
if strings.Contains(r.URL.Path, "assets") {
// Then we're responding to GetAssets
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
err := encoder.Encode(map[string][]vpp.Asset{"assets": config.Assets})
if err != nil {
panic(err)
}
return
}
resp := []byte(`{"locationName": "Fleet Location One"}`)
if strings.Contains(r.URL.RawQuery, "invalidToken") {
// This replicates the response sent back from Apple's VPP endpoints when an invalid
// token is passed. For more details see:
// https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
// https://developer.apple.com/documentation/devicemanagement/client_config
// https://developer.apple.com/documentation/devicemanagement/errorresponse
// Note that the Apple server returns 200 in this case.
resp = []byte(`{"errorNumber": 9622,"errorMessage": "Invalid authentication token"}`)
}
if strings.Contains(r.URL.RawQuery, "serverError") {
resp = []byte(`{"errorNumber": 9603,"errorMessage": "Internal server error"}`)
w.WriteHeader(http.StatusInternalServerError)
}
_, _ = w.Write(resp)
}))
dev_mode.SetOverride("FLEET_DEV_VPP_URL", srv.URL, t)
t.Cleanup(srv.Close)
}
func StartAndServeVPPServer(t *testing.T) {
config := &AppleVPPConfigSrvConf{
Assets: []vpp.Asset{
{
AdamID: "1",
PricingParam: "STDQ",
AvailableCount: 12,
},
{
AdamID: "2",
PricingParam: "STDQ",
AvailableCount: 3,
},
},
SerialNumbers: []string{"123", "456"},
}
StartVPPApplyServer(t, config)
// Set up the VPP proxy metadata server using the new format
// This replaces the old iTunes API format
vppProxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-bearer-token" || r.Header.Get("vpp-token") == "" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error": "unauthorized"}`))
return
}
// deviceFamilies: "mac" -> osx platform, "iphone" -> ios platform, "ipad" -> ios platform
db := map[string]string{
// macos app
"1": `{
"id": "1",
"attributes": {
"name": "App 1",
"platformAttributes": {
"osx": {
"bundleId": "a-1",
"artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"},
"latestVersionInfo": {"versionDisplay": "1.0.0"}
}
},
"deviceFamilies": ["mac"]
}
}`,
// macos, ios, ipados app
"2": `{
"id": "2",
"attributes": {
"name": "App 2",
"platformAttributes": {
"osx": {
"bundleId": "b-2",
"artwork": {"url": "https://example.com/images/2-mac/{w}x{h}.{f}"},
"latestVersionInfo": {"versionDisplay": "1.2.3"}
},
"ios": {
"bundleId": "b-2",
"artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"},
"latestVersionInfo": {"versionDisplay": "2.0.0"}
}
},
"deviceFamilies": ["mac", "iphone", "ipad"]
}
}`,
// ipados app
"3": `{
"id": "3",
"attributes": {
"name": "App 3",
"platformAttributes": {
"ios": {
"bundleId": "c-3",
"artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"},
"latestVersionInfo": {"versionDisplay": "3.0.0"}
}
},
"deviceFamilies": ["ipad"]
}
}`,
}
adamIDString := r.URL.Query().Get("ids")
adamIDs := strings.Split(adamIDString, ",")
var objs []string
for _, a := range adamIDs {
if obj, ok := db[a]; ok {
objs = append(objs, obj)
}
}
_, _ = w.Write(fmt.Appendf(nil, `{"data": [%s]}`, strings.Join(objs, ",")))
}))
vppProxyAuthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"fleetServerSecret": "test-bearer-token"}`))
}))
t.Cleanup(vppProxySrv.Close)
t.Cleanup(vppProxyAuthSrv.Close)
dev_mode.SetOverride("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL", vppProxySrv.URL, t)
dev_mode.SetOverride("FLEET_DEV_VPP_PROXY_AUTH_URL", vppProxyAuthSrv.URL, t)
}
type MockPusher struct{}
func (MockPusher) Push(ctx context.Context, ids []string) (map[string]*push.Response, error) {
m := make(map[string]*push.Response, len(ids))
for _, id := range ids {
m[id] = &push.Response{Id: id}
}
return m, nil
}
type MemKeyValueStore struct {
m sync.Map
}
func NewMemKeyValueStore() *MemKeyValueStore {
return &MemKeyValueStore{}
}
func (m *MemKeyValueStore) Set(ctx context.Context, key string, value string, expireTime time.Duration) error {
m.m.Store(key, value)
return nil
}
func (m *MemKeyValueStore) Get(ctx context.Context, key string) (*string, error) {
v, ok := m.m.Load(key)
if !ok {
return nil, nil
}
vAsString := v.(string)
return &vAsString, nil
}
func AddLabelMocks(ds *mock.Store) {
var deletedLabels []string
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return []*fleet.LabelSpec{
{
Name: "a",
Description: "A global label",
LabelMembershipType: fleet.LabelMembershipTypeManual,
Hosts: []string{"host2", "host3"},
},
{
Name: "b",
Description: "Another global label",
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
Query: "SELECT 1 from osquery_info",
},
}, nil
}
ds.ApplyLabelSpecsWithAuthorFunc = func(ctx context.Context, specs []*fleet.LabelSpec, authorID *uint) (err error) {
return nil
}
ds.SetAsideLabelsFunc = func(ctx context.Context, teamID *uint, names []string, user fleet.User) error {
return nil
}
ds.LabelByNameFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) (*fleet.Label, error) {
return &fleet.Label{ID: 1, Name: name}, nil
}
ds.DeleteLabelFunc = func(ctx context.Context, name string, filter fleet.TeamFilter) error {
deletedLabels = append(deletedLabels, name)
return nil
}
ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) {
validLabels := map[string]*fleet.Label{
"a": {
ID: 1,
Name: "a",
},
"b": {
ID: 2,
Name: "b",
},
}
found := make(map[string]*fleet.Label)
for _, l := range names {
if label, ok := validLabels[l]; ok {
found[l] = label
}
}
return found, nil
}
}
type notFoundError struct{}
var _ fleet.NotFoundError = (*notFoundError)(nil)
func (e *notFoundError) IsNotFound() bool {
return true
}
func (e *notFoundError) Error() string {
return ""
}