Files
fleet/server/service/scripts_test.go
T
Jordan Montgomery 89f67544b4 Add support for fleet vars in scripts(controls scripts, software scripts/script-only packages and setup experience scripts) (#49781)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49511  and #46837 as a whole

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

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

- [x] QA'd all new/changed functionality manually


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

* **New Features**
* Added support for Fleet built-in variables in host scripts, software
installer scripts, setup-experience scripts, and maintained-app
installer scripts.
* Variables are resolved per host at execution time; saved content
remains unexpanded.
* **Bug Fixes**
* Requests now validate Fleet variables up-front, with clear
script-specific error messages for unsupported variables.
* Added improved messaging when variable resolution fails during
execution.
* Enforced Fleet Premium licensing for script/installer flows that use
Fleet variables.
* **Documentation**
* Documented supported variables and Premium requirements, including
usage examples.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-27 11:24:42 +02:00

1401 lines
54 KiB
Go

package service
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/require"
)
func TestHostRunScript(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
// use a custom implementation of checkAuthErr as the service call will fail
// with a not found error for unknown host in case of authorization success,
// and the package-wide checkAuthErr requires no error.
checkAuthErr := func(t *testing.T, shouldFail bool, err error) {
if shouldFail {
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
} else if err != nil {
require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error())
}
}
teamHost := &fleet.Host{ID: 1, Hostname: "host-team", TeamID: ptr.Uint(1), SeenTime: time.Now(), OrbitNodeKey: ptr.String("abc")}
noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil, SeenTime: time.Now(), OrbitNodeKey: ptr.String("def")}
nonExistingHost := &fleet.Host{ID: 3, Hostname: "no-such-host", TeamID: nil}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.HostFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
if hostID == 1 {
return teamHost, nil
}
if hostID == 2 {
return noTeamHost, nil
}
return nil, newNotFoundError()
}
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
return &fleet.HostScriptResult{HostID: request.HostID, ScriptContents: request.ScriptContents, ExecutionID: "abc"}, nil
}
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
return nil, nil
}
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
return &fleet.Script{ID: id}, nil
}
ds.GetScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
return []byte("echo"), nil
}
ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hostID, scriptID uint) (bool, error) { return false, nil }
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error {
return nil
}
t.Run("authorization checks", func(t *testing.T) {
testCases := []struct {
name string
user *fleet.User
scriptID *uint
shouldFailTeamWrite bool
shouldFailGlobalWrite bool
}{
{
name: "global admin",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
},
{
name: "global admin saved",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
},
{
name: "global maintainer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
},
{
name: "global maintainer saved",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
},
{
name: "global observer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "global observer saved",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "global observer+",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "global observer+ saved",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "global gitops",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "global gitops saved",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team admin, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
},
{
name: "team admin, belongs to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
},
{
name: "team maintainer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
},
{
name: "team maintainer, belongs to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
},
{
name: "team observer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer, belongs to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer+, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer+, belongs to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team gitops, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team gitops, belongs to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team admin, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team admin, DOES NOT belong to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team maintainer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team maintainer, DOES NOT belong to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer, DOES NOT belong to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer+, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team observer+, DOES NOT belong to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team gitops, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
{
name: "team gitops, DOES NOT belong to team, saved",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
scriptID: ptr.Uint(1),
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
contents := "abc"
if tt.scriptID != nil {
contents = ""
}
_, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: contents, ScriptID: tt.scriptID}, 0)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
_, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: teamHost.ID, ScriptContents: contents, ScriptID: tt.scriptID}, 0)
checkAuthErr(t, tt.shouldFailTeamWrite, err)
if tt.scriptID == nil {
// a non-existing host is authorized as for global write (because we can't know what team it belongs to)
_, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: nonExistingHost.ID, ScriptContents: "abc"}, 0)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
}
// test auth for run sync saved script by name
if tt.scriptID != nil {
ds.GetScriptIDByNameFunc = func(ctx context.Context, name string, teamID *uint) (uint, error) {
return *tt.scriptID, nil
}
_, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: "", ScriptID: nil, ScriptName: "Foo", TeamID: 1}, 1)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
_, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: teamHost.ID, ScriptContents: "", ScriptID: nil, ScriptName: "Foo", TeamID: 1}, 1)
checkAuthErr(t, tt.shouldFailTeamWrite, err)
}
})
}
})
t.Run("script contents validation", func(t *testing.T) {
testCases := []struct {
name string
script string
wantErr string
}{
{"empty script", "", "One of 'script_id', 'script_contents', or 'script_name' is required."},
{"overly long script", strings.Repeat("a", fleet.UnsavedScriptMaxRuneLen+1), "Script is too large."},
{"large script", strings.Repeat("a", fleet.UnsavedScriptMaxRuneLen), ""},
{"invalid utf8", "\xff\xfa", "Wrong data format."},
{"valid without hashbang", "echo 'a'", ""},
{"valid with posix hashbang", "#!/bin/sh\necho 'a'", ""},
{"valid with usr bash hashbang", "#!/usr/bin/bash\necho 'a'", ""},
{"valid with bash hashbang", "#!/bin/bash\necho 'a'", ""},
{"valid with bash hashbang and arguments", "#!/bin/bash -x\necho 'a'", ""},
{"valid with usr zsh hashbang", "#!/usr/bin/zsh\necho 'a'", ""},
{"valid with zsh hashbang", "#!/bin/zsh\necho 'a'", ""},
{"valid with zsh hashbang and arguments", "#!/bin/zsh -x\necho 'a'", ""},
{"valid with hashbang and spacing", "#! /bin/sh \necho 'a'", ""},
{"valid with hashbang and Windows newline", "#! /bin/sh \r\necho 'a'", ""},
{"invalid hashbang", "#!/bin/ksh\necho 'a'", "Interpreter not supported."},
}
ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin})
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
_, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: tt.script}, 0)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
})
}
func TestGetScriptResult(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
const (
noTeamHostExecID = "no-team-host"
teamHostExecID = "team-host"
nonExistingHostExecID = "non-existing-host"
)
checkAuthErr := func(t *testing.T, shouldFail bool, err error) {
if shouldFail {
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
} else if err != nil {
require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error())
}
}
teamHost := &fleet.Host{ID: 1, Hostname: "host-team", TeamID: ptr.Uint(1), SeenTime: time.Now()}
noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil, SeenTime: time.Now()}
nonExistingHost := &fleet.Host{ID: 3, Hostname: "no-such-host", TeamID: nil}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.GetHostScriptExecutionResultFunc = func(ctx context.Context, executionID string) (*fleet.HostScriptResult, error) {
switch executionID {
case noTeamHostExecID:
return &fleet.HostScriptResult{HostID: noTeamHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil
case teamHostExecID:
return &fleet.HostScriptResult{HostID: teamHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil
case nonExistingHostExecID:
return &fleet.HostScriptResult{HostID: nonExistingHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil
default:
return nil, newNotFoundError()
}
}
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
if hostID == 1 {
return teamHost, nil
}
if hostID == 2 {
return noTeamHost, nil
}
return nil, newNotFoundError()
}
testCases := []struct {
name string
user *fleet.User
shouldFailTeamRead bool
shouldFailGlobalRead bool
}{
{
name: "global admin",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global maintainer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global observer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global observer+",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global gitops",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team admin, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team maintainer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team observer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team observer+, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team gitops, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team admin, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team maintainer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team observer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team observer+, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team gitops, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
_, err := svc.GetScriptResult(ctx, noTeamHostExecID)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.GetScriptResult(ctx, teamHostExecID)
checkAuthErr(t, tt.shouldFailTeamRead, err)
// a non-existing host is authorized as for global write (because we can't know what team it belongs to)
_, err = svc.GetScriptResult(ctx, nonExistingHostExecID)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
})
}
}
func TestSavedScripts(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
withLFContents := "echo\necho"
withCRLFContents := "echo\r\necho"
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.NewScriptFunc = func(ctx context.Context, script *fleet.Script) (*fleet.Script, error) {
require.Equal(t, withLFContents, script.ScriptContents)
newScript := *script
newScript.ID = 1
return &newScript, nil
}
const (
team1ScriptID = 1
noTeamScriptID = 2
)
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
switch id {
case team1ScriptID:
return &fleet.Script{ID: id, TeamID: ptr.Uint(1)}, nil
default:
return &fleet.Script{ID: id}, nil
}
}
ds.GetScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
return []byte("echo"), nil
}
ds.DeleteScriptFunc = func(ctx context.Context, id uint) error {
return nil
}
ds.ListScriptsFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
return nil, &fleet.PaginationMetadata{}, nil
}
ds.TeamWithExtrasFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: 0}, nil
}
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error {
return nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}
testCases := []struct {
name string
user *fleet.User
shouldFailTeamWrite bool
shouldFailGlobalWrite bool
shouldFailTeamRead bool
shouldFailGlobalRead bool
}{
{
name: "global admin",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global maintainer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global observer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global observer+",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global gitops",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: false,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team admin, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team maintainer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team observer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team observer+, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team gitops, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
shouldFailTeamWrite: false,
shouldFailGlobalWrite: true,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team admin, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team maintainer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team observer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team observer+, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team gitops, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
shouldFailTeamWrite: true,
shouldFailGlobalWrite: true,
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
_, err := svc.NewScript(ctx, nil, "test.ps1", strings.NewReader(withCRLFContents))
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
err = svc.DeleteScript(ctx, noTeamScriptID)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
_, _, err = svc.ListScripts(ctx, nil, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, _, err = svc.GetScript(ctx, noTeamScriptID, false)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, _, err = svc.GetScript(ctx, noTeamScriptID, true)
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, err = svc.NewScript(ctx, ptr.Uint(1), "test.sh", strings.NewReader(withLFContents))
checkAuthErr(t, tt.shouldFailTeamWrite, err)
err = svc.DeleteScript(ctx, team1ScriptID)
checkAuthErr(t, tt.shouldFailTeamWrite, err)
_, _, err = svc.ListScripts(ctx, ptr.Uint(1), fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailTeamRead, err)
_, _, err = svc.GetScript(ctx, team1ScriptID, false)
checkAuthErr(t, tt.shouldFailTeamRead, err)
_, _, err = svc.GetScript(ctx, team1ScriptID, true)
checkAuthErr(t, tt.shouldFailTeamRead, err)
})
}
}
func TestHostScriptDetailsAuth(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
testCases := []struct {
name string
user *fleet.User
shouldFailTeamRead bool
shouldFailGlobalRead bool
}{
{
name: "global admin",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global maintainer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global observer",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global observer+",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
shouldFailTeamRead: false,
shouldFailGlobalRead: false,
},
{
name: "global gitops",
user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team admin, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team maintainer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team observer, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team observer+, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamRead: false,
shouldFailGlobalRead: true,
},
{
name: "team gitops, belongs to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team admin, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team maintainer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team observer, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team observer+, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
{
name: "team gitops, DOES NOT belong to team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
shouldFailTeamRead: true,
shouldFailGlobalRead: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
t.Run("no team host script details", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
require.Equal(t, uint(42), hostID)
return &fleet.Host{ID: hostID}, nil
}
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
require.Nil(t, teamID)
return []*fleet.HostScriptDetail{}, nil, nil
}
_, _, err := svc.GetHostScriptDetails(ctx, 42, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
})
t.Run("team host script details", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
require.Equal(t, uint(42), hostID)
return &fleet.Host{ID: hostID, TeamID: ptr.Uint(1)}, nil
}
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
require.NotNil(t, teamID)
require.Equal(t, uint(1), *teamID)
return []*fleet.HostScriptDetail{}, nil, nil
}
_, _, err := svc.GetHostScriptDetails(ctx, 42, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailTeamRead, err)
})
t.Run("host not found", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
require.Equal(t, uint(43), hostID)
return nil, &notFoundError{}
}
_, _, err := svc.GetHostScriptDetails(ctx, 43, fleet.ListOptions{})
if tt.shouldFailGlobalRead {
checkAuthErr(t, tt.shouldFailGlobalRead, err)
} else {
require.True(t, fleet.IsNotFound(err))
}
})
})
}
}
func TestBatchScriptExecute(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
t.Run("error if hosts do not all belong to the same team as script", func(t *testing.T) {
ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
return []*fleet.Host{
{ID: 1, TeamID: ptr.Uint(1)},
{ID: 2, TeamID: ptr.Uint(1)},
{ID: 3, TeamID: ptr.Uint(2)},
}, nil
}
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
if id == 1 {
return &fleet.Script{ID: id, TeamID: ptr.Uint(1)}, nil
}
return &fleet.Script{ID: id}, nil
}
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecute(ctx, 1, []uint{1, 2, 3}, nil, nil)
require.Error(t, err)
require.ErrorContains(t, err, "all hosts must be on the same fleet as the script")
})
t.Run("error if both host_ids and filters are specified", func(t *testing.T) {
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecute(ctx, 1, []uint{1, 2, 3}, &map[string]interface{}{"foo": "bar"}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "cannot specify both host_ids and filters")
})
t.Run("error if filters are specified but no team_id", func(t *testing.T) {
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecute(ctx, 1, nil, &map[string]interface{}{"label_id": float64(123)}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "filters must include a team filter")
})
t.Run("error if filters match too many hosts", func(t *testing.T) {
hosts := make([]*fleet.Host, 5001)
for i := 0; i < 5001; i++ {
hosts[i] = &fleet.Host{ID: uint(i + 1), TeamID: ptr.Uint(1)} // nolint:gosec // ignore G115
}
ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {
return hosts, nil
}
ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
return hosts, nil
}
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
if id == 1 {
return &fleet.Script{ID: id, TeamID: ptr.Uint(1)}, nil
}
return &fleet.Script{ID: id}, nil
}
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecute(ctx, 1, nil, &map[string]interface{}{"team_id": float64(1)}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "too_many_hosts")
})
t.Run("happy path", func(t *testing.T) {
var requestedHostIds []uint
ds.BatchExecuteScriptFunc = func(ctx context.Context, userID *uint, scriptID uint, hostIDs []uint) (string, error) {
requestedHostIds = hostIDs
return "", errors.New("ok")
}
ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
return []*fleet.Host{
{ID: 1, TeamID: ptr.Uint(1)},
{ID: 2, TeamID: ptr.Uint(1)},
}, nil
}
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
if id == 1 {
return &fleet.Script{ID: id, TeamID: ptr.Uint(1)}, nil
}
return &fleet.Script{ID: id}, nil
}
ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {
return []*fleet.Host{
{ID: 3, TeamID: ptr.Uint(1)},
{ID: 4, TeamID: ptr.Uint(1)},
}, nil
}
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecute(ctx, 1, []uint{1, 2}, nil, nil)
require.Error(t, err)
require.ErrorContains(t, err, "ok")
require.Equal(t, []uint{1, 2}, requestedHostIds)
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err = svc.BatchScriptExecute(ctx, 1, nil, &map[string]interface{}{"team_id": float64(1)}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "ok")
require.Equal(t, []uint{3, 4}, requestedHostIds)
})
t.Run("authorization checks", func(t *testing.T) {
checkAuthErr := func(t *testing.T, shouldFail bool, err error) {
if shouldFail {
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
} else if err != nil {
require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error())
}
}
// The script and the hosts it runs on all belong to team 1.
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
return &fleet.Script{ID: id, TeamID: new(uint(1))}, nil
}
ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
return []*fleet.Host{
{ID: 1, TeamID: new(uint(1))},
{ID: 2, TeamID: new(uint(1))},
}, nil
}
// Return a non-authorization error so an authorized caller gets past the
// authz checks; checkAuthErr only cares whether the error is Forbidden.
ds.BatchExecuteScriptFunc = func(ctx context.Context, userID *uint, scriptID uint, hostIDs []uint) (string, error) {
return "", errors.New("ok")
}
testCases := []struct {
name string
user *fleet.User
shouldFail bool
}{
{
name: "global admin",
user: &fleet.User{GlobalRole: new(fleet.RoleAdmin)},
shouldFail: false,
},
{
name: "global maintainer",
user: &fleet.User{GlobalRole: new(fleet.RoleMaintainer)},
shouldFail: false,
},
{
name: "global observer",
user: &fleet.User{GlobalRole: new(fleet.RoleObserver)},
shouldFail: true,
},
{
name: "global observer+",
user: &fleet.User{GlobalRole: new(fleet.RoleObserverPlus)},
shouldFail: true,
},
{
name: "global gitops",
user: &fleet.User{GlobalRole: new(fleet.RoleGitOps)},
shouldFail: true,
},
{
name: "global technician",
user: &fleet.User{GlobalRole: new(fleet.RoleTechnician)},
shouldFail: true,
},
{
name: "team admin, belongs to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
shouldFail: false,
},
{
name: "team maintainer, belongs to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
shouldFail: false,
},
{
name: "team observer, belongs to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
shouldFail: true,
},
{
name: "team observer+, belongs to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
shouldFail: true,
},
{
name: "team gitops, belongs to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
shouldFail: true,
},
{
name: "team technician, belongs to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleTechnician}}},
shouldFail: true,
},
{
name: "team admin, does not belong to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
shouldFail: true,
},
{
name: "team maintainer, does not belong to script team",
user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
shouldFail: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
_, err := svc.BatchScriptExecute(ctx, 1, []uint{1, 2}, nil, nil)
checkAuthErr(t, tt.shouldFail, err)
})
}
})
}
func TestWipeHostRequestDecodeBody(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
body io.Reader
expectedError string
expectation func(t *testing.T, req *fleet.WipeHostRequest)
}{
{
name: "empty body",
body: strings.NewReader(""),
expectation: func(t *testing.T, req *fleet.WipeHostRequest) {
require.Nil(t, req.Metadata)
},
},
{
name: "doWipe",
body: strings.NewReader(`{"windows": {"wipe_type": "doWipe"}}`),
expectation: func(t *testing.T, req *fleet.WipeHostRequest) {
require.NotNil(t, req.Metadata)
require.NotNil(t, req.Metadata.Windows)
require.Equal(t, fleet.MDMWindowsWipeTypeDoWipe, req.Metadata.Windows.WipeType)
},
},
{
name: "doWipeProtected",
body: strings.NewReader(`{"windows": {"wipe_type": "doWipeProtected"}}`),
expectation: func(t *testing.T, req *fleet.WipeHostRequest) {
require.NotNil(t, req.Metadata)
require.NotNil(t, req.Metadata.Windows)
require.Equal(t, fleet.MDMWindowsWipeTypeDoWipeProtected, req.Metadata.Windows.WipeType)
},
},
{
name: "invalid wipe type",
body: strings.NewReader(`{"windows": {"wipe_type": "doWipeProtectedII"}}`),
expectedError: "failed to unmarshal request body",
},
{
name: "empty payload",
body: strings.NewReader(`{}`),
expectation: func(t *testing.T, req *fleet.WipeHostRequest) {
require.NotNil(t, req.Metadata)
require.Nil(t, req.Metadata.Windows)
},
},
{
name: "windows field is null",
body: strings.NewReader(`{"windows": null}`),
expectation: func(t *testing.T, req *fleet.WipeHostRequest) {
require.NotNil(t, req.Metadata)
require.Nil(t, req.Metadata.Windows)
},
},
{
name: "empty wipe type",
body: strings.NewReader(`{"windows": {"wipe_type": null}}`),
expectedError: "failed to unmarshal request body",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sut := fleet.WipeHostRequest{}
err := sut.DecodeBody(ctx, tc.body, nil, nil)
if tc.expectedError != "" {
require.ErrorContains(t, err, tc.expectedError)
} else {
require.NoError(t, err)
tc.expectation(t, &sut)
}
})
}
}
func TestBatchScriptExecutionSummary(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
t.Run("not found", func(t *testing.T) {
// While these not found tests seem useless, it's mainly to check that we do an authz check so we don't return 500s but the actual not found.
ds.BatchExecuteSummaryFunc = func(ctx context.Context, executionID string) (*fleet.BatchActivity, error) {
return nil, platform_mysql.NotFound("batch execution").WithName(executionID)
}
t.Run("global admin → not-found", func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecutionSummary(ctx, "unknown-id")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
t.Run("team admin → forbidden, no leak", func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}},
}})
_, err := svc.BatchScriptExecutionSummary(ctx, "unknown-id")
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
require.False(t, fleet.IsNotFound(err))
})
t.Run("real DB error → wrapped, not IsNotFound", func(t *testing.T) {
ds.BatchExecuteSummaryFunc = func(ctx context.Context, executionID string) (*fleet.BatchActivity, error) {
return nil, errors.New("connection refused")
}
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecutionSummary(ctx, "any-id")
require.Error(t, err)
require.False(t, fleet.IsNotFound(err))
})
})
}
func TestBatchScriptCancel(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
t.Run("not found", func(t *testing.T) {
ds.ListBatchScriptExecutionsFunc = func(ctx context.Context, f fleet.BatchExecutionStatusFilter) ([]fleet.BatchActivity, error) {
return nil, platform_mysql.NotFound("batch execution").WithName(*f.ExecutionID)
}
t.Run("global admin → not-found", func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
err := svc.BatchScriptCancel(ctx, "unknown-id")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
t.Run("team admin → forbidden, no leak", func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}},
}})
err := svc.BatchScriptCancel(ctx, "unknown-id")
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
require.False(t, fleet.IsNotFound(err))
})
t.Run("real DB error → wrapped, not IsNotFound", func(t *testing.T) {
ds.ListBatchScriptExecutionsFunc = func(ctx context.Context, f fleet.BatchExecutionStatusFilter) ([]fleet.BatchActivity, error) {
return nil, errors.New("connection refused")
}
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
err := svc.BatchScriptCancel(ctx, "any-id")
require.Error(t, err)
require.False(t, fleet.IsNotFound(err))
})
})
}
func TestBatchScriptExecutionStatus(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
t.Run("not found", func(t *testing.T) {
ds.ListBatchScriptExecutionsFunc = func(ctx context.Context, f fleet.BatchExecutionStatusFilter) ([]fleet.BatchActivity, error) {
return nil, platform_mysql.NotFound("batch execution").WithName(*f.ExecutionID)
}
t.Run("global admin → not-found", func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecutionStatus(ctx, "unknown-id")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
t.Run("team admin → forbidden, no leak", func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}},
}})
_, err := svc.BatchScriptExecutionStatus(ctx, "unknown-id")
require.Error(t, err)
require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
require.False(t, fleet.IsNotFound(err))
})
t.Run("real DB error → wrapped, not IsNotFound", func(t *testing.T) {
ds.ListBatchScriptExecutionsFunc = func(ctx context.Context, f fleet.BatchExecutionStatusFilter) ([]fleet.BatchActivity, error) {
return nil, errors.New("connection refused")
}
ctx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
_, err := svc.BatchScriptExecutionStatus(ctx, "any-id")
require.Error(t, err)
require.False(t, fleet.IsNotFound(err))
})
})
}
func TestScriptFleetVariablesValidation(t *testing.T) {
newSvc := func(t *testing.T, tier string) (fleet.Service, context.Context, *mock.Store) {
ds := new(mock.Store)
lic := &fleet.LicenseInfo{Tier: tier, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: lic, SkipCreateTestUsers: true})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.HostFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, SeenTime: time.Now(), OrbitNodeKey: new("abc")}, nil
}
ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, onlyShowInternal bool) ([]*fleet.HostScriptResult, error) {
return nil, nil
}
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
return &fleet.HostScriptResult{HostID: request.HostID, ScriptContents: request.ScriptContents, ExecutionID: "exec-1"}, nil
}
ds.NewScriptFunc = func(ctx context.Context, script *fleet.Script) (*fleet.Script, error) {
newScript := *script
newScript.ID = 1
return &newScript, nil
}
ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) {
return &fleet.Script{ID: id, Name: "test.sh"}, nil
}
ds.UpdateScriptContentsFunc = func(ctx context.Context, scriptID uint, contents string) (*fleet.Script, error) {
return &fleet.Script{ID: scriptID, Name: "test.sh", ScriptContents: contents}, nil
}
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error {
return nil
}
return svc, ctx, ds
}
const (
supportedVarContents = "echo $FLEET_VAR_HOST_UUID on ${FLEET_VAR_HOST_PLATFORM}"
unsupportedVarContents = "echo $FLEET_VAR_NONEXISTENT"
unsupportedVarErrMsg = "Fleet variable $FLEET_VAR_NONEXISTENT is not supported in scripts."
)
t.Run("premium", func(t *testing.T) {
svc, ctx, ds := newSvc(t, fleet.TierPremium)
t.Run("run host script", func(t *testing.T) {
_, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: 1, ScriptContents: unsupportedVarContents}, 0)
require.ErrorContains(t, err, unsupportedVarErrMsg)
res, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: 1, ScriptContents: supportedVarContents}, 0)
require.NoError(t, err)
// contents are stored unexpanded; they resolve when fleetd fetches the script
require.Equal(t, supportedVarContents, res.ScriptContents)
})
t.Run("new script", func(t *testing.T) {
_, err := svc.NewScript(ctx, nil, "test.sh", strings.NewReader(unsupportedVarContents))
require.ErrorContains(t, err, unsupportedVarErrMsg)
saved, err := svc.NewScript(ctx, nil, "test.sh", strings.NewReader(supportedVarContents))
require.NoError(t, err)
require.Equal(t, supportedVarContents, saved.ScriptContents)
})
t.Run("update script", func(t *testing.T) {
_, err := svc.UpdateScript(ctx, 1, strings.NewReader(unsupportedVarContents))
require.ErrorContains(t, err, unsupportedVarErrMsg)
saved, err := svc.UpdateScript(ctx, 1, strings.NewReader(supportedVarContents))
require.NoError(t, err)
require.Equal(t, supportedVarContents, saved.ScriptContents)
})
t.Run("batch set scripts", func(t *testing.T) {
badPayload := []fleet.ScriptPayload{{Name: "test.sh", ScriptContents: []byte(unsupportedVarContents)}}
goodPayload := []fleet.ScriptPayload{{Name: "test.sh", ScriptContents: []byte(supportedVarContents)}}
// unsupported variables are rejected on dry run too, keyed on the
// indexed field so callers can tell which script failed
for _, dryRun := range []bool{true, false} {
_, err := svc.BatchSetScripts(ctx, nil, nil, badPayload, dryRun)
require.ErrorContains(t, err, unsupportedVarErrMsg, "dryRun=%v", dryRun)
require.ErrorContains(t, err, "scripts[0]", "dryRun=%v", dryRun)
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) {
require.Len(t, scripts, 1)
require.Equal(t, supportedVarContents, scripts[0].ScriptContents)
return []fleet.ScriptResponse{{ID: 1, Name: "test.sh"}}, nil
}
_, err := svc.BatchSetScripts(ctx, nil, nil, goodPayload, false)
require.NoError(t, err)
require.True(t, ds.BatchSetScriptsFuncInvoked)
})
})
t.Run("free returns license error for any variable", func(t *testing.T) {
svc, ctx, _ := newSvc(t, fleet.TierFree)
for name, contents := range map[string]string{
"supported": supportedVarContents,
"unsupported": unsupportedVarContents,
} {
t.Run(name, func(t *testing.T) {
_, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: 1, ScriptContents: contents}, 0)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
_, err = svc.NewScript(ctx, nil, "test.sh", strings.NewReader(contents))
require.ErrorIs(t, err, fleet.ErrMissingLicense)
_, err = svc.UpdateScript(ctx, 1, strings.NewReader(contents))
require.ErrorIs(t, err, fleet.ErrMissingLicense)
_, err = svc.BatchSetScripts(ctx, nil, nil,
[]fleet.ScriptPayload{{Name: "test.sh", ScriptContents: []byte(contents)}}, true)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
})
}
t.Run("variable-free scripts still work", func(t *testing.T) {
res, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: 1, ScriptContents: "echo hello"}, 0)
require.NoError(t, err)
require.Equal(t, "echo hello", res.ScriptContents)
_, err = svc.NewScript(ctx, nil, "test.sh", strings.NewReader("echo hello"))
require.NoError(t, err)
})
})
}