package service
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
ma "github.com/fleetdm/fleet/v4/ee/maintained-apps"
"github.com/fleetdm/fleet/v4/ee/server/calendar"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/cron"
"github.com/fleetdm/fleet/v4/server/datastore/filesystem"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/live_query/live_query_mock"
"github.com/fleetdm/fleet/v4/server/mdm"
maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
"github.com/fleetdm/fleet/v4/server/policies"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/pubsub"
commonCalendar "github.com/fleetdm/fleet/v4/server/service/calendar"
"github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy"
"github.com/fleetdm/fleet/v4/server/service/redis_lock"
"github.com/fleetdm/fleet/v4/server/service/schedule"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
googleCalendar "google.golang.org/api/calendar/v3"
"gopkg.in/guregu/null.v3"
)
func TestIntegrationsEnterprise(t *testing.T) {
testingSuite := new(integrationEnterpriseTestSuite)
testingSuite.withServer.s = &testingSuite.Suite
suite.Run(t, testingSuite)
}
type integrationEnterpriseTestSuite struct {
withServer
suite.Suite
redisPool fleet.RedisPool
calendarSchedule *schedule.Schedule
softwareInstallStore fleet.SoftwareInstallerStore
softwareTitleIconStore fleet.SoftwareTitleIconStore
lq *live_query_mock.MockLiveQuery
}
func (s *integrationEnterpriseTestSuite) SetupSuite() {
s.withDS.SetupSuite("integrationEnterpriseTestSuite")
s.redisPool = redistest.SetupRedis(s.T(), "integration_enterprise", false, false, false)
s.lq = live_query_mock.New(s.T())
var calendarSchedule *schedule.Schedule
// Create a software install store
dir := s.T().TempDir()
softwareInstallStore, err := filesystem.NewSoftwareInstallerStore(dir)
require.NoError(s.T(), err)
s.softwareInstallStore = softwareInstallStore
// Create a software title icon store
iconDir := s.T().TempDir()
softwareTitleIconStore, err := filesystem.NewSoftwareTitleIconStore(iconDir)
require.NoError(s.T(), err)
s.softwareTitleIconStore = softwareTitleIconStore
config := TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
Pool: s.redisPool,
Rs: pubsub.NewInmemQueryResults(),
Lq: s.lq,
Logger: log.NewLogfmtLogger(os.Stdout),
EnableCachedDS: true,
StartCronSchedules: []TestNewScheduleFunc{
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
// We set 24-hour interval so that it only runs when triggered.
var err error
cronLog := log.NewJSONLogger(os.Stdout)
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
cronLog = kitlog.NewNopLogger()
}
calendarSchedule, err = cron.NewCalendarSchedule(
ctx, s.T().Name(), s.ds, redis_lock.NewLock(s.redisPool), config.CalendarConfig{Periodicity: 24 * time.Hour},
cronLog,
)
return calendarSchedule, err
}
},
},
SoftwareInstallStore: softwareInstallStore,
SoftwareTitleIconStore: softwareTitleIconStore,
ConditionalAccessMicrosoftProxy: mockedConditionalAccessMicrosoftProxyInstance,
}
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
config.Logger = kitlog.NewNopLogger()
}
users, server := RunServerForTestsWithDS(s.T(), s.ds, &config)
s.server = server
s.users = users
s.token = s.getTestAdminToken()
s.cachedTokens = make(map[string]string)
s.calendarSchedule = calendarSchedule
}
func (s *integrationEnterpriseTestSuite) TearDownTest() {
// reset the mock
s.lq.Mock = mock.Mock{}
s.withServer.commonTearDownTest(s.T())
}
func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
teamNameDecomposed := teamName + "ᄀ" + "ᅡ" // Add a decomposed Unicode character
team := &fleet.Team{
Name: teamNameDecomposed,
Description: "desc team1",
}
teamName += "가"
s.Do("POST", "/api/latest/fleet/teams", team, http.StatusOK)
// Create global calendar integration
calendarEmail := "service@example.com"
calendarWebhookUrl := "https://example.com/webhook"
s.DoRaw(
"PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(
`{
"integrations": {
"google_calendar": [{
"api_key_json": {
"client_email": %q,
"private_key": "testKey"
},
"domain": "example.com"
}]
}
}`, calendarEmail,
)), http.StatusOK,
)
// updates a team, no secret is provided so it will keep the one generated
// automatically when the team was created.
agentOpts := json.RawMessage(`{"config": {"views": {"foo": "bar"}}, "overrides": {"platforms": {"darwin": {"views": {"bar": "qux"}}}}}`)
features := json.RawMessage(`{
"enable_host_users": false,
"enable_software_inventory": false,
"additional_queries": {"foo": "bar"}
}`)
// must not use applyTeamSpecsRequest and marshal it as JSON, as it will set
// all keys to their zerovalue, and some are only valid with mdm enabled.
teamSpecs := map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"agent_options": agentOpts,
"features": &features,
"mdm": map[string]any{
"macos_updates": map[string]any{
"minimum_version": "10.15.0",
"deadline": "2021-01-01",
},
"ios_updates": map[string]any{
"minimum_version": "17.5.1",
"deadline": "2024-07-23",
},
"ipados_updates": map[string]any{
"minimum_version": "18.0",
"deadline": "2024-08-24",
},
},
},
},
}
var applyResp applyTeamSpecsResponse
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
team, err := s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Equal(t, applyResp.TeamIDsByName[teamName], team.ID)
assert.Len(t, team.Secrets, 1)
require.JSONEq(t, string(agentOpts), string(*team.Config.AgentOptions))
require.Equal(t, fleet.Features{
EnableHostUsers: false,
EnableSoftwareInventory: false,
AdditionalQueries: ptr.RawMessage(json.RawMessage(`{"foo": "bar"}`)),
}, team.Config.Features)
require.Equal(t, fleet.TeamMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("17.5.1"),
Deadline: optjson.SetString("2024-07-23"),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("18.0"),
Deadline: optjson.SetString("2024-08-24"),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.Int{Set: true},
GracePeriodDays: optjson.Int{Set: true},
},
MacOSSetup: fleet.MacOSSetup{
// because the MacOSSetup was marshalled to JSON to be saved in the DB,
// it did get marshalled, and then when unmarshalled it was set (but
// null).
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
Script: optjson.String{Set: true},
Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
ManualAgentInstall: optjson.Bool{Set: true},
},
// because the WindowsSettings was marshalled to JSON to be saved in the DB,
// it did get marshalled, and then when unmarshalled it was set (but
// empty).
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
AndroidSettings: fleet.AndroidSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, team.Config.MDM)
// an activity was created for team spec applied
s.lastActivityMatches(fleet.ActivityTypeAppliedSpecTeam{}.ActivityName(), fmt.Sprintf(`{"teams": [{"id": %d, "name": %q}]}`, team.ID, team.Name), 0)
// Create team policy
teamPolicy, err := s.ds.NewTeamPolicy(
context.Background(), team.ID, nil, fleet.PolicyPayload{Name: "TestSpecTeamPolicy", Query: "SELECT 1"},
)
require.NoError(t, err)
defer func() {
_, err = s.ds.DeleteTeamPolicies(context.Background(), team.ID, []uint{teamPolicy.ID})
require.NoError(t, err)
}()
// Apply calendar integration
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"integrations": map[string]any{
"google_calendar": map[string]any{
"enable_calendar_events": true,
"webhook_url": calendarWebhookUrl,
},
},
},
},
}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NotNil(t, team.Config.Integrations.GoogleCalendar)
assert.Equal(t, calendarWebhookUrl, team.Config.Integrations.GoogleCalendar.WebhookURL)
assert.True(t, team.Config.Integrations.GoogleCalendar.Enable)
// dry-run with invalid windows updates
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamNameDecomposed,
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": -1,
"grace_period_days": 1,
},
},
},
},
}
res := s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "deadline_days must be an integer between 0 and 30")
// apply valid windows updates settings
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamNameDecomposed,
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": 1,
"grace_period_days": 1,
},
},
},
},
}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Equal(t, applyResp.TeamIDsByName[teamName], team.ID)
require.Equal(t, fleet.TeamMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("17.5.1"),
Deadline: optjson.SetString("2024-07-23"),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("18.0"),
Deadline: optjson.SetString("2024-08-24"),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(1),
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
Script: optjson.String{Set: true},
Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
ManualAgentInstall: optjson.Bool{Set: true},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
AndroidSettings: fleet.AndroidSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, team.Config.MDM)
// get the team via the GET endpoint, check that it properly returns the mdm settings
var getTmResp getTeamResponse
s.DoJSON("GET", "/api/latest/fleet/teams/"+fmt.Sprint(team.ID), nil, http.StatusOK, &getTmResp)
require.Equal(t, fleet.TeamMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("17.5.1"),
Deadline: optjson.SetString("2024-07-23"),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("18.0"),
Deadline: optjson.SetString("2024-08-24"),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(1),
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
Script: optjson.String{Set: true},
Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
ManualAgentInstall: optjson.Bool{Set: true},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
AndroidSettings: fleet.AndroidSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, getTmResp.Team.Config.MDM)
// get the team via the list teams endpoint, check that it properly returns the mdm settings
var listTmResp listTeamsResponse
s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listTmResp, "query", teamName)
require.True(t, len(listTmResp.Teams) > 0)
require.Equal(t, team.ID, listTmResp.Teams[0].ID)
require.Equal(t, fleet.TeamMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("17.5.1"),
Deadline: optjson.SetString("2024-07-23"),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("18.0"),
Deadline: optjson.SetString("2024-08-24"),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(1),
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
Script: optjson.String{Set: true},
Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
ManualAgentInstall: optjson.Bool{Set: true},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
AndroidSettings: fleet.AndroidSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, listTmResp.Teams[0].Config.MDM)
// dry-run with invalid agent options
agentOpts = json.RawMessage(`{"config": {"nope": 1}}`)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamNameDecomposed,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest, "dry_run", "true")
// dry-run with empty body
res = s.DoRaw("POST", "/api/latest/fleet/spec/teams", nil, http.StatusBadRequest, "force", "true")
errBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Contains(t, string(errBody), `"Expected JSON Body"`)
// dry-run with invalid top-level key
s.Do("POST", "/api/latest/fleet/spec/teams", json.RawMessage(`{
"specs": [
{"name": "team_name_1", "unknown_key": true}
]
}`), http.StatusBadRequest, "dry_run", "true")
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Contains(t, string(*team.Config.AgentOptions), `"foo": "bar"`) // unchanged
// dry-run with valid agent options and custom macos settings
agentOpts = json.RawMessage(`{"config": {"views": {"foo": "qux"}}}`)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamNameDecomposed,
"agent_options": agentOpts,
"mdm": map[string]any{
"macos_settings": map[string]any{
"custom_settings": []string{"foo", "bar"},
},
},
},
},
}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't update macos_settings because MDM features aren't turned on in Fleet.")
// dry-run with macos disk encryption set to false, no error
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"mdm": map[string]any{
"macos_settings": map[string]any{
"enable_disk_encryption": false,
},
},
},
},
}
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true")
assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName)
// dry-run with macos disk encryption set to true
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"mdm": map[string]any{
"macos_settings": map[string]any{
"enable_disk_encryption": true,
},
},
},
},
}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't update macos_settings because MDM features aren't turned on in Fleet.")
// dry-run with macos enable release device set to false, no error
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"mdm": map[string]any{
"macos_setup": map[string]any{
"enable_release_device_manually": false,
},
},
},
},
}
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true")
assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName)
// dry-run with macos enable release device manually set to true
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"mdm": map[string]any{
"macos_setup": map[string]any{
"enable_release_device_manually": true,
},
},
},
},
}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't update macos_setup because MDM features aren't turned on in Fleet.")
// dry-run with invalid host_expiry_settings.host_expiry_window
teamSpecs = map[string]any{
"specs": []map[string]any{
{
"name": teamName,
"host_expiry_settings": map[string]any{
"host_expiry_window": 0,
"host_expiry_enabled": true,
},
},
},
}
// Update team
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "host expiry window")
// Create team (coverage should show that this validation was covered for both update and create)
teamSpecs["specs"].([]map[string]any)[0]["name"] = teamName + "invalid host expiry window"
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "host expiry window")
// dry-run with valid agent options only
agentOpts = json.RawMessage(`{"config": {"views": {"foo": "qux"}}}`)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Contains(t, string(*team.Config.AgentOptions), `"foo": "bar"`) // unchanged
require.Empty(t, team.Config.MDM.MacOSSettings.CustomSettings) // unchanged
require.False(t, team.Config.MDM.EnableDiskEncryption) // unchanged
// apply without agent options specified
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// agent options are unchanged, not cleared
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Contains(t, string(*team.Config.AgentOptions), `"foo": "bar"`) // unchanged
// apply with agent options specified but null
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"agent_options": json.RawMessage(`null`),
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// agent options are cleared
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Nil(t, team.Config.AgentOptions)
// force with invalid agent options
agentOpts = json.RawMessage(`{"config": {"foo": "qux"}}`)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "force", "true")
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Contains(t, string(*team.Config.AgentOptions), `"foo": "qux"`)
// force create new team with invalid top-level key
s.Do("POST", "/api/latest/fleet/spec/teams", json.RawMessage(`{
"specs": [
{"name": "team_with_invalid_key", "unknown_key": true}
]
}`), http.StatusOK, "force", "true")
_, err = s.ds.TeamByName(context.Background(), "team_with_invalid_key")
require.NoError(t, err)
// invalid agent options command-line flag
agentOpts = json.RawMessage(`{"command_line_flags": {"nope": 1}}`)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
// valid agent options command-line flag
agentOpts = json.RawMessage(`{"command_line_flags": {"enable_tables": "abcd"}}`)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
require.Contains(t, string(*team.Config.AgentOptions), `"enable_tables": "abcd"`)
// creates a team with default agent options
user, err := s.ds.UserByEmail(context.Background(), "admin1@example.com")
require.NoError(t, err)
teams, err := s.ds.ListTeams(context.Background(), fleet.TeamFilter{User: user}, fleet.ListOptions{})
require.NoError(t, err)
require.True(t, len(teams) >= 1)
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": "team2",
},
},
}
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
teams, err = s.ds.ListTeams(context.Background(), fleet.TeamFilter{User: user}, fleet.ListOptions{})
require.NoError(t, err)
assert.True(t, len(teams) >= 2)
team, err = s.ds.TeamByName(context.Background(), "team2")
require.NoError(t, err)
require.Equal(t, applyResp.TeamIDsByName["team2"], team.ID)
appConfig, err := s.ds.AppConfig(context.Background())
require.NoError(t, err)
defaultOpts := `{"config": {"options": {"logger_plugin": "tls", "pack_delimiter": "/", "logger_tls_period": 10, "distributed_plugin": "tls", "disable_distributed": false, "logger_tls_endpoint": "/api/osquery/log", "distributed_interval": 10, "distributed_tls_max_attempts": 3}, "decorators": {"load": ["SELECT uuid AS host_uuid FROM system_info;", "SELECT hostname AS hostname FROM system_info;"]}}, "overrides": {}}`
assert.Len(t, team.Secrets, 1) // secret gets created automatically for a new team when none is supplied.
require.NotNil(t, team.Config.AgentOptions)
require.JSONEq(t, defaultOpts, string(*team.Config.AgentOptions))
require.Equal(t, appConfig.Features, team.Config.Features)
// an activity was created for the newly created team via the applied spec
s.lastActivityMatches(fleet.ActivityTypeAppliedSpecTeam{}.ActivityName(), fmt.Sprintf(`{"teams": [{"id": %d, "name": %q}]}`, team.ID, team.Name), 0)
// updates
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": "team2",
"secrets": []fleet.EnrollSecret{{Secret: "ABC"}},
"features": nil,
},
},
}
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
team, err = s.ds.TeamByName(context.Background(), "team2")
require.NoError(t, err)
require.Equal(t, applyResp.TeamIDsByName["team2"], team.ID)
require.Len(t, team.Secrets, 1)
assert.Equal(t, "ABC", team.Secrets[0].Secret)
}
func (s *integrationEnterpriseTestSuite) TestTeamSpecsPermissions() {
t := s.T()
//
// Setup test
//
// Create two teams, team1 and team2.
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 42,
Name: "team1",
Description: "desc team1",
})
require.NoError(t, err)
team2, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 43,
Name: "team2",
Description: "desc team2",
})
require.NoError(t, err)
// Create a new admin for team1.
password := test.GoodPassword
email := "admin-team1@example.com"
u := &fleet.User{
Name: "admin team1",
Email: email,
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team1,
Role: fleet.RoleAdmin,
},
},
}
require.NoError(t, u.SetPassword(password, 10, 10))
_, err = s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
//
// Start testing team specs with admin of team1.
//
s.setTokenForTest(t, "admin-team1@example.com", test.GoodPassword)
// Should allow editing own team.
agentOpts := json.RawMessage(`{"config": {"views": {"foo": "bar2"}}, "overrides": {"platforms": {"darwin": {"views": {"bar": "qux"}}}}}`)
editTeam1Spec := map[string]any{
"specs": []any{
map[string]any{
"name": team1.Name,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", editTeam1Spec, http.StatusOK)
team1b, err := s.ds.Team(context.Background(), team1.ID)
require.NoError(t, err)
require.Equal(t, *team1b.Config.AgentOptions, agentOpts)
// Should not allow editing other teams.
editTeam2Spec := map[string]any{
"specs": []any{
map[string]any{
"name": team2.Name,
"agent_options": agentOpts,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", editTeam2Spec, http.StatusForbidden)
}
func (s *integrationEnterpriseTestSuite) TestTeamSchedule() {
t := s.T()
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 42,
Name: "team1",
Description: "desc team1",
})
require.NoError(t, err)
ts := getTeamScheduleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Scheduled, 0)
qr, err := s.ds.NewQuery(
context.Background(),
&fleet.Query{
Name: "TestQueryTeamPolicy",
Description: "Some description",
Query: "select * from osquery;",
ObserverCanRun: true,
Saved: true,
Logging: fleet.LoggingSnapshot,
},
)
require.NoError(t, err)
gsParams := teamScheduleQueryRequest{ScheduledQueryPayload: fleet.ScheduledQueryPayload{
QueryID: &qr.ID,
Interval: ptr.Uint(42),
}}
r := teamScheduleQueryResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", team1.ID), gsParams, http.StatusOK, &r)
ts = getTeamScheduleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Scheduled, 1)
assert.Equal(t, uint(42), ts.Scheduled[0].Interval)
assert.Contains(t, ts.Scheduled[0].Name, "Copy of TestQueryTeamPolicy")
assert.NotEqual(t, qr.ID, ts.Scheduled[0].QueryID) // it creates a new query (copy)
id := ts.Scheduled[0].ID
modifyResp := modifyTeamScheduleResponse{}
modifyParams := modifyTeamScheduleRequest{ScheduledQueryPayload: fleet.ScheduledQueryPayload{Interval: ptr.Uint(55)}}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule/%d", team1.ID, id), modifyParams, http.StatusOK, &modifyResp)
// just to satisfy my paranoia, wanted to make sure the contents of the json would work
s.DoRaw("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule/%d", team1.ID, id), []byte(`{"interval": 77}`), http.StatusOK)
ts = getTeamScheduleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Scheduled, 1)
assert.Equal(t, uint(77), ts.Scheduled[0].Interval)
deleteResp := deleteTeamScheduleResponse{}
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule/%d", team1.ID, id), nil, http.StatusOK, &deleteResp)
ts = getTeamScheduleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Scheduled, 0)
}
func (s *integrationEnterpriseTestSuite) TestTeamPolicies() {
t := s.T()
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 42,
Name: "team1" + t.Name(),
Description: "desc team1",
})
require.NoError(t, err)
oldToken := s.token
t.Cleanup(func() {
s.token = oldToken
})
password := test.GoodPassword
email := "testteam@user.com"
u := &fleet.User{
Name: "test team user",
Email: email,
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team1,
Role: fleet.RoleMaintainer,
},
},
}
require.NoError(t, u.SetPassword(password, 10, 10))
_, err = s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.token = s.getTestToken(email, password)
ts := listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 0)
require.Len(t, ts.InheritedPolicies, 0)
// create a global policy
gpol, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: "TestGlobalPolicy", Query: "SELECT 1"})
require.NoError(t, err)
defer func() {
_, err := s.ds.DeleteGlobalPolicies(context.Background(), []uint{gpol.ID})
require.NoError(t, err)
}()
qr, err := s.ds.NewQuery(context.Background(), &fleet.Query{
Name: "TestQuery2",
Description: "Some description",
Query: "select * from osquery;",
ObserverCanRun: true,
Logging: fleet.LoggingSnapshot,
})
require.NoError(t, err)
tpParams := teamPolicyRequest{
QueryID: &qr.ID,
Resolution: "some team resolution",
}
r := teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), tpParams, http.StatusOK, &r)
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 1)
assert.Equal(t, "TestQuery2", ts.Policies[0].Name)
assert.Equal(t, "select * from osquery;", ts.Policies[0].Query)
assert.Equal(t, "Some description", ts.Policies[0].Description)
require.NotNil(t, ts.Policies[0].Resolution)
assert.Equal(t, "some team resolution", *ts.Policies[0].Resolution)
require.Len(t, ts.InheritedPolicies, 1)
assert.Equal(t, gpol.Name, ts.InheritedPolicies[0].Name)
assert.Equal(t, gpol.ID, ts.InheritedPolicies[0].ID)
tc := countTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/count", team1.ID), nil, http.StatusOK, &tc)
require.Nil(t, tc.Err)
require.Equal(t, 1, tc.Count)
gc := countGlobalPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/policies/count", nil, http.StatusOK, &gc)
require.Nil(t, gc.Err)
require.Equal(t, 1, gc.Count)
// Test merge inherited
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts, "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc")
require.Len(t, ts.Policies, 2)
require.Nil(t, ts.InheritedPolicies)
assert.Equal(t, "TestQuery2", ts.Policies[0].Name)
assert.Equal(t, "select * from osquery;", ts.Policies[0].Query)
assert.Equal(t, "Some description", ts.Policies[0].Description)
require.NotNil(t, ts.Policies[0].Resolution)
assert.Equal(t, "some team resolution", *ts.Policies[0].Resolution)
assert.Equal(t, gpol.Name, ts.Policies[1].Name)
assert.Equal(t, gpol.ID, ts.Policies[1].ID)
countResp := countTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/count", team1.ID), nil, http.StatusOK, &countResp, "merge_inherited", "true")
require.Nil(t, countResp.Err)
require.Equal(t, 2, countResp.Count)
// Test delete
deletePolicyParams := deleteTeamPoliciesRequest{IDs: []uint{ts.Policies[0].ID}}
deletePolicyResp := deleteTeamPoliciesResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", team1.ID), deletePolicyParams, http.StatusOK, &deletePolicyResp)
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 0)
}
func (s *integrationEnterpriseTestSuite) TestNoTeamPolicies() {
t := s.T()
ctx := context.Background()
//
// Test a global admin can read and write "No team" policies.
//
// List "No team" policies.
ts := listTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 0)
require.Len(t, ts.InheritedPolicies, 0)
// Create a placeholder global policy.
_, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{
Name: "globalPolicy1",
Query: "SELECT 0;",
})
require.NoError(t, err)
// Create a "No team" policy.
tpParams := teamPolicyRequest{
Name: "noTeamPolicy1",
Query: "SELECT 1;",
}
r := teamPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &r)
require.NotNil(t, r.Policy.TeamID)
require.Zero(t, *r.Policy.TeamID)
// Test that we can't create a policy with the same name under "No team" domain.
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusConflict, &r)
// Create a second "No team" policy.
tpParams = teamPolicyRequest{
Name: "noTeamPolicy2",
Query: "SELECT 2;",
}
r = teamPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &r)
require.NotNil(t, r.Policy.TeamID)
require.Zero(t, *r.Policy.TeamID)
// List "No team" policies.
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 2)
assert.Equal(t, "noTeamPolicy1", ts.Policies[0].Name)
assert.Equal(t, "SELECT 1;", ts.Policies[0].Query)
require.NotNil(t, ts.Policies[0].TeamID)
require.Zero(t, *ts.Policies[0].TeamID)
assert.Equal(t, "noTeamPolicy2", ts.Policies[1].Name)
assert.Equal(t, "SELECT 2;", ts.Policies[1].Query)
require.NotNil(t, ts.Policies[1].TeamID)
require.Zero(t, *ts.Policies[1].TeamID)
require.Len(t, ts.InheritedPolicies, 1)
assert.Equal(t, "globalPolicy1", ts.InheritedPolicies[0].Name)
assert.Equal(t, "SELECT 0;", ts.InheritedPolicies[0].Query)
assert.Nil(t, ts.InheritedPolicies[0].TeamID)
// Test policy count for "No team" policies.
tc := countTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusOK, &tc)
require.Equal(t, 2, tc.Count)
// Test merge inherited for "No team" policies.
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts, "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc")
require.Len(t, ts.Policies, 3)
require.Nil(t, ts.InheritedPolicies)
assert.Equal(t, "noTeamPolicy1", ts.Policies[0].Name)
assert.Equal(t, "SELECT 1;", ts.Policies[0].Query)
assert.Equal(t, "noTeamPolicy2", ts.Policies[1].Name)
assert.Equal(t, "SELECT 2;", ts.Policies[1].Query)
assert.Equal(t, "globalPolicy1", ts.Policies[2].Name)
assert.Equal(t, "SELECT 0;", ts.Policies[2].Query)
// Test merge inherited count for "No team" policies.
countResp := countTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusOK, &countResp, "merge_inherited", "true")
require.Nil(t, countResp.Err)
require.Equal(t, 3, countResp.Count)
// Test deleting "No team" policies.
deletePolicyParams := deleteTeamPoliciesRequest{
IDs: []uint{ts.Policies[0].ID},
}
deletePolicyResp := deleteTeamPoliciesResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies/delete", deletePolicyParams, http.StatusOK, &deletePolicyResp)
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 1)
assert.Equal(t, "noTeamPolicy2", ts.Policies[0].Name)
assert.Equal(t, "SELECT 2;", ts.Policies[0].Query)
noTeamPolicy2 := ts.Policies[0]
//
// Test that a team admin is not allowed to access "No team" policies.
//
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team1",
})
require.NoError(t, err)
oldToken := s.token
t.Cleanup(func() {
s.token = oldToken
})
password := test.GoodPassword
email := "testteam@user.com"
team1Admin := &fleet.User{
Name: "test team user",
Email: email,
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team1,
Role: fleet.RoleAdmin,
},
},
}
require.NoError(t, team1Admin.SetPassword(password, 10, 10))
_, err = s.ds.NewUser(context.Background(), team1Admin)
require.NoError(t, err)
s.token = s.getTestToken(email, password)
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusForbidden, &ts)
tpParams = teamPolicyRequest{
Name: "noTeamPolicy1",
Query: "SELECT 1;",
}
r = teamPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusForbidden, &r)
tc = countTeamPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusForbidden, &tc)
deletePolicyParams = deleteTeamPoliciesRequest{
IDs: []uint{noTeamPolicy2.ID},
}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies/delete", deletePolicyParams, http.StatusForbidden, &deleteTeamPoliciesResponse{})
}
func (s *integrationEnterpriseTestSuite) TestTeamQueries() {
t := s.T()
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 42,
Name: "team1" + t.Name(),
Description: "desc team1",
})
require.NoError(t, err)
oldToken := s.token
t.Cleanup(func() {
s.token = oldToken
})
// create global query
params := fleet.QueryPayload{
Name: ptr.String("global1"),
Query: ptr.String("select * from time;"),
}
var createQueryResp createQueryResponse
s.DoJSON("POST", "/api/latest/fleet/queries", ¶ms, http.StatusOK, &createQueryResp)
defer s.cleanupQuery(createQueryResp.Query.ID)
// create team query
params = fleet.QueryPayload{
Name: ptr.String("team1"),
Query: ptr.String("select * from time;"),
TeamID: ptr.Uint(team1.ID),
}
createQueryResp = createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", ¶ms, http.StatusOK, &createQueryResp)
defer s.cleanupQuery(createQueryResp.Query.ID)
// list team queries
var listQueriesResp listQueriesResponse
s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQueriesResp, "team_id", fmt.Sprint(team1.ID))
require.Len(t, listQueriesResp.Queries, 1)
assert.Equal(t, "team1", listQueriesResp.Queries[0].Name)
// list merged team queries
s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQueriesResp, "team_id", fmt.Sprint(team1.ID), "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc")
require.Len(t, listQueriesResp.Queries, 2)
assert.Equal(t, "team1", listQueriesResp.Queries[0].Name)
assert.Equal(t, "global1", listQueriesResp.Queries[1].Name)
}
func (s *integrationEnterpriseTestSuite) TestModifyTeamEnrollSecrets() {
t := s.T()
// Create new team and set initial secret
teamName := t.Name() + "secretTeam"
team := &fleet.Team{
Name: teamName,
Description: "secretTeam description",
Secrets: []*fleet.EnrollSecret{{Secret: "initialSecret"}},
}
s.Do("POST", "/api/latest/fleet/teams", team, http.StatusOK)
team, err := s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
assert.Equal(t, team.Secrets[0].Secret, "initialSecret")
// Test replace existing secrets
req := json.RawMessage(`{"secrets": [{"secret": "testSecret1"},{"secret": "testSecret2"}]}`)
var resp teamEnrollSecretsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), req, http.StatusOK, &resp)
require.Len(t, resp.Secrets, 2)
team, err = s.ds.TeamByName(context.Background(), teamName)
require.NoError(t, err)
assert.Equal(t, "testSecret1", team.Secrets[0].Secret)
assert.Equal(t, "testSecret2", team.Secrets[1].Secret)
// Test delete all enroll secrets
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), json.RawMessage(`{"secrets": []}`), http.StatusOK, &resp)
require.Len(t, resp.Secrets, 0)
// Test bad requests
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), json.RawMessage(`{"foo": [{"secret": "testSecret3"}]}`), http.StatusUnprocessableEntity, &resp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), json.RawMessage(`{}`), http.StatusUnprocessableEntity, &resp)
// too many secrets
secrets := createEnrollSecrets(t, fleet.MaxEnrollSecretsCount+1)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), json.RawMessage(`{"secrets": `+string(jsonMustMarshal(t, secrets))+`}`), http.StatusUnprocessableEntity, &resp)
}
func (s *integrationEnterpriseTestSuite) TestAvailableTeams() {
t := s.T()
// create a new team
team := &fleet.Team{
Name: "Available Team",
Description: "Available Team description",
}
s.Do("POST", "/api/latest/fleet/teams", team, http.StatusOK)
team, err := s.ds.TeamByName(context.Background(), "Available Team")
require.NoError(t, err)
// create a new user
user := &fleet.User{
Name: "Available Teams User",
Email: "available@example.com",
GlobalRole: ptr.String("observer"),
}
err = user.SetPassword(test.GoodPassword, 10, 10)
require.Nil(t, err)
user, err = s.ds.NewUser(context.Background(), user)
require.Nil(t, err)
// test available teams for user assigned to global role
var getResp getUserResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", user.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, user.ID, getResp.User.ID)
assert.Equal(t, ptr.String("observer"), getResp.User.GlobalRole)
assert.Len(t, getResp.User.Teams, 0) // teams is empty if user has a global role
assert.Len(t, getResp.AvailableTeams, 1) // available teams includes all teams if user has a global role
assert.Equal(t, getResp.AvailableTeams[0].Name, "Available Team")
// assign user to a team
user.GlobalRole = nil
user.Teams = []fleet.UserTeam{{Team: *team, Role: "maintainer"}}
err = s.ds.SaveUser(context.Background(), user)
require.NoError(t, err)
// test available teams for user assigned to team role
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", user.ID), nil, http.StatusOK, &getResp)
assert.Equal(t, user.ID, getResp.User.ID)
assert.Nil(t, getResp.User.GlobalRole)
assert.Len(t, getResp.User.Teams, 1)
assert.Equal(t, getResp.User.Teams[0].Name, "Available Team")
assert.Len(t, getResp.AvailableTeams, 1)
assert.Equal(t, getResp.AvailableTeams[0].Name, "Available Team")
// test available teams returned by `/me` endpoint
session, err := s.ds.NewSession(context.Background(), user.ID, 64)
require.NoError(t, err)
resp := s.DoRawWithHeaders("GET", "/api/latest/fleet/me", []byte(""), http.StatusOK, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", session.Key),
})
err = json.NewDecoder(resp.Body).Decode(&getResp)
require.NoError(t, err)
assert.Equal(t, user.ID, getResp.User.ID)
assert.Nil(t, getResp.User.GlobalRole)
assert.Len(t, getResp.User.Teams, 1)
assert.Equal(t, getResp.User.Teams[0].Name, "Available Team")
assert.Len(t, getResp.AvailableTeams, 1)
assert.Equal(t, getResp.AvailableTeams[0].Name, "Available Team")
}
func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() {
t := s.T()
name := strings.ReplaceAll(t.Name(), "/", "_")
// create a new team
team := &fleet.Team{
Name: name,
Description: "Team description",
Secrets: []*fleet.EnrollSecret{{Secret: "DEF"}},
}
var tmResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &tmResp)
assert.Equal(t, team.Name, tmResp.Team.Name)
require.Len(t, tmResp.Team.Secrets, 1)
assert.Equal(t, "DEF", tmResp.Team.Secrets[0].Secret)
// create a duplicate team (same name)
team2 := &fleet.Team{
Name: name,
Description: "Team2 description",
Secrets: []*fleet.EnrollSecret{{Secret: "GHI"}},
}
tmResp.Team = nil
s.DoJSON("POST", "/api/latest/fleet/teams", team2, http.StatusConflict, &tmResp)
// create a team with reserved team names; should be case-insensitive
teamReserved := &fleet.Team{
Name: "no TeAm",
Description: "description",
Secrets: []*fleet.EnrollSecret{{Secret: "foobar"}},
}
r := s.Do("POST", "/api/latest/fleet/teams", teamReserved, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), `"No team" is a reserved team name`)
teamReserved.Name = "AlL TeaMS"
r = s.Do("POST", "/api/latest/fleet/teams", teamReserved, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), `"All teams" is a reserved team name`)
// create a team with too many secrets
team3 := &fleet.Team{
Name: name + "lots_of_secrets",
Description: "Team3 description",
Secrets: createEnrollSecrets(t, fleet.MaxEnrollSecretsCount+1),
}
tmResp.Team = nil
s.DoJSON("POST", "/api/latest/fleet/teams", team3, http.StatusUnprocessableEntity, &tmResp)
// create a team with invalid host expiry window
team4 := &fleet.TeamPayload{
Name: ptr.String(name + "invalid host_expiry_window"),
Description: ptr.String("Team4 description"),
Secrets: []*fleet.EnrollSecret{{Secret: "TEAM4"}},
HostExpirySettings: &fleet.HostExpirySettings{
HostExpiryEnabled: true,
HostExpiryWindow: -1,
},
}
s.DoJSON("POST", "/api/latest/fleet/teams", team4, http.StatusUnprocessableEntity, &tmResp)
// list teams matching name of one team
var listResp listTeamsResponse
s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listResp, "query", name, "per_page", "2")
require.Len(t, listResp.Teams, 1)
assert.Equal(t, team.Name, listResp.Teams[0].Name)
assert.NotNil(t, listResp.Teams[0].Config.AgentOptions)
tm1ID := listResp.Teams[0].ID
// same as above, with leading/trailing whitespace
s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listResp, "query", " "+name+" ", "per_page", "2")
require.Len(t, listResp.Teams, 1)
assert.Equal(t, team.Name, listResp.Teams[0].Name)
assert.NotNil(t, listResp.Teams[0].Config.AgentOptions)
// same as above, no match
s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listResp, "query", " nope ", "per_page", "2")
require.Len(t, listResp.Teams, 0)
// get team
var getResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), nil, http.StatusOK, &getResp)
assert.Equal(t, team.Name, getResp.Team.Name)
assert.NotNil(t, getResp.Team.Config.AgentOptions)
// modify team
team.Description = "Alt " + team.Description
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), team, http.StatusOK, &tmResp)
assert.Contains(t, tmResp.Team.Description, "Alt ")
// modify team's disk encryption, impossible without mdm enabled
res := s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{
MDM: &fleet.TeamPayloadMDM{
EnableDiskEncryption: optjson.SetBool(true),
},
}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `Couldn't update macos_settings because MDM features aren't turned on in Fleet.`)
// modify a team with a NULL config
defaultFeatures := fleet.Features{}
defaultFeatures.ApplyDefaultsForNewInstalls()
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `UPDATE teams SET config = NULL WHERE id = ? `, tm1ID)
return err
})
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), team, http.StatusOK, &tmResp)
assert.Equal(t, defaultFeatures, tmResp.Team.Config.Features)
// modify a team with an empty config
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `UPDATE teams SET config = '{}' WHERE id = ? `, tm1ID)
return err
})
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), team, http.StatusOK, &tmResp)
assert.Equal(t, defaultFeatures, tmResp.Team.Config.Features)
assert.False(t, tmResp.Team.Config.HostExpirySettings.HostExpiryEnabled)
// modify non-existing team
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID+1), team, http.StatusNotFound, &tmResp)
// modify team host expiry
modifyExpiry := fleet.TeamPayload{
HostExpirySettings: &fleet.HostExpirySettings{
HostExpiryEnabled: true,
HostExpiryWindow: 10,
},
}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyExpiry, http.StatusOK, &tmResp)
assert.Equal(t, *modifyExpiry.HostExpirySettings, tmResp.Team.Config.HostExpirySettings)
// invalid team host expiry (<= 0)
modifyExpiry.HostExpirySettings.HostExpiryWindow = 0
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyExpiry, http.StatusUnprocessableEntity, &tmResp)
// try to rename to reserved names
r = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{Name: ptr.String("no TEAM")}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), `"No team" is a reserved team name`)
r = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{Name: ptr.String("ALL teAMs")}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), `"All teams" is a reserved team name`)
// Modify team's calendar config
modifyCalendar := fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
WebhookURL: "https://example.com/modified",
},
},
}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyCalendar, http.StatusOK, &tmResp)
assert.Equal(t, modifyCalendar.Integrations.GoogleCalendar, tmResp.Team.Config.Integrations.GoogleCalendar)
// Illegal team calendar config
modifyCalendar.Integrations.GoogleCalendar.Enable = true
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyCalendar, http.StatusUnprocessableEntity, &tmResp)
// list team users
var usersResp listUsersResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), nil, http.StatusOK, &usersResp)
assert.Len(t, usersResp.Users, 0)
// list team users - non-existing team
usersResp.Users = nil
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID+1), nil, http.StatusNotFound, &usersResp)
// create a new user
user := &fleet.User{
Name: "Team User",
Email: "user@example.com",
GlobalRole: ptr.String("observer"),
}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
user, err := s.ds.NewUser(context.Background(), user)
require.NoError(t, err)
// add a team user
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), modifyTeamUsersRequest{Users: []fleet.TeamUser{{User: *user, Role: fleet.RoleObserver}}}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Users, 1)
assert.Equal(t, user.ID, tmResp.Team.Users[0].ID)
// add a team user - non-existing team
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID+1), modifyTeamUsersRequest{Users: []fleet.TeamUser{{User: *user, Role: fleet.RoleObserver}}}, http.StatusNotFound, &tmResp)
// add a team user - invalid user role
tmResp.Team = nil
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), modifyTeamUsersRequest{Users: []fleet.TeamUser{{User: *user, Role: "foobar"}}}, http.StatusUnprocessableEntity, &tmResp)
// search for that user
usersResp.Users = nil
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), nil, http.StatusOK, &usersResp, "query", "user")
require.Len(t, usersResp.Users, 1)
assert.Equal(t, user.ID, usersResp.Users[0].ID)
// search for unknown user
usersResp.Users = nil
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), nil, http.StatusOK, &usersResp, "query", "notauser")
require.Len(t, usersResp.Users, 0)
// delete team user
tmResp.Team = nil
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), modifyTeamUsersRequest{Users: []fleet.TeamUser{{User: fleet.User{ID: user.ID}}}}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Users, 0)
// delete team user - unknown user
tmResp.Team = nil
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), modifyTeamUsersRequest{Users: []fleet.TeamUser{{User: fleet.User{ID: user.ID + 1}}}}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Users, 0)
// delete team user - unknown team
tmResp.Team = nil
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID+1), modifyTeamUsersRequest{Users: []fleet.TeamUser{{User: fleet.User{ID: user.ID}}}}, http.StatusNotFound, &tmResp)
// modify team agent options with invalid options
tmResp.Team = nil
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"x": "y"
}`), http.StatusBadRequest, &tmResp)
// modify team agent options with invalid key
badRes := s.Do("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"bad_key": 1
}`), http.StatusBadRequest)
errText := extractServerErrorText(badRes.Body)
require.Contains(t, errText, "unsupported key provided")
// modify team agent options with correct options under the wrong key
badRes = s.Do("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"distributed_tls_max_attempts": 3
}`), http.StatusBadRequest)
errText = extractServerErrorText(badRes.Body)
require.Contains(t, errText, "\"distributed_tls_max_attempts\" should be part of the \"config.options\" object")
badRes = s.Do("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"config": { "options": { "logger_plugin": 3 } }
}`), http.StatusBadRequest)
errText = extractServerErrorText(badRes.Body)
require.Contains(t, errText, "\"logger_plugin\" should be part of the \"command_line_flags\" object")
badRes = s.Do("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"update_channels": { "config": 1 }
}`), http.StatusBadRequest)
errText = extractServerErrorText(badRes.Body)
require.Contains(t, errText, "\"config\" should be part of the top level object")
// modify team agent options with invalid platform options
tmResp.Team = nil
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(
`{"overrides": {
"platforms": {
"linux": null
}
}}`,
), http.StatusBadRequest, &tmResp)
// modify team agent options with invalid options, but force-apply them
tmResp.Team = nil
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"config": {
"x": "y"
}
}`), http.StatusOK, &tmResp, "force", "true")
require.Contains(t, string(*tmResp.Team.Config.AgentOptions), `"x": "y"`)
// modify team agent options with valid options
tmResp.Team = nil
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"config": {
"options": {
"aws_debug": true
}
}
}`), http.StatusOK, &tmResp)
require.Contains(t, string(*tmResp.Team.Config.AgentOptions), `"aws_debug": true`)
// modify team agent using invalid options with dry-run
tmResp.Team = nil
resp := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"config": {
"options": {
"aws_debug": "not-a-bool"
}
}
}`), http.StatusBadRequest, "dry_run", "true")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "invalid value type at 'options.aws_debug': expected bool but got string")
// modify team agent using valid options with dry-run
tmResp.Team = nil
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID), json.RawMessage(`{
"config": {
"options": {
"aws_debug": false
}
}
}`), http.StatusOK, &tmResp, "dry_run", "true")
require.Contains(t, string(*tmResp.Team.Config.AgentOptions), `"aws_debug": true`) // left unchanged
// list activities, it should have created one for edited_agent_options
s.lastActivityMatches(fleet.ActivityTypeEditedAgentOptions{}.ActivityName(), fmt.Sprintf(`{"global": false, "team_id": %d, "team_name": %q}`, tm1ID, team.Name), 0)
// modify team agent options - unknown team
tmResp.Team = nil
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tm1ID+1), json.RawMessage(`{}`), http.StatusNotFound, &tmResp)
// get team enroll secrets
var secResp teamEnrollSecretsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1ID), nil, http.StatusOK, &secResp)
require.Len(t, secResp.Secrets, 1)
assert.Equal(t, team.Secrets[0].Secret, secResp.Secrets[0].Secret)
// get team enroll secrets- unknown team: does not return 404 because reads directly
// the secrets table, does not load the team first (which would be unnecessary except
// for checking that it exists)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1ID+1), nil, http.StatusOK, &secResp)
assert.Len(t, secResp.Secrets, 0)
// delete team
var delResp deleteTeamResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), nil, http.StatusOK, &delResp)
// delete team again, now an unknown team
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), nil, http.StatusNotFound, &delResp)
}
func (s *integrationEnterpriseTestSuite) TestTeamSecretsAreObfuscated() {
t := s.T()
// -----------------
// Set up test data
// -----------------
teams := []*fleet.Team{
{
Name: "Team One",
Description: "Team description",
Secrets: []*fleet.EnrollSecret{{Secret: "DEF"}},
},
{
Name: "Team Two",
Description: "Team Two description",
Secrets: []*fleet.EnrollSecret{{Secret: "ABC"}},
},
}
for _, team := range teams {
_, err := s.ds.NewTeam(context.Background(), team)
require.NoError(t, err)
}
global_obs := &fleet.User{
Name: "Global Obs",
Email: "global_obs@example.com",
GlobalRole: ptr.String(fleet.RoleObserver),
}
global_obs_plus := &fleet.User{
Name: "Global Obs+",
Email: "global_obs_plus@example.com",
GlobalRole: ptr.String(fleet.RoleObserverPlus),
}
team_obs := &fleet.User{
Name: "Team Obs",
Email: "team_obs@example.com",
Teams: []fleet.UserTeam{
{
Team: *teams[0],
Role: fleet.RoleObserver,
},
{
Team: *teams[1],
Role: fleet.RoleAdmin,
},
},
}
team_obs_plus := &fleet.User{
Name: "Team Obs Plus",
Email: "team_obs_plus@example.com",
Teams: []fleet.UserTeam{
{
Team: *teams[0],
Role: fleet.RoleAdmin,
},
{
Team: *teams[1],
Role: fleet.RoleObserverPlus,
},
},
}
users := []*fleet.User{global_obs, global_obs_plus, team_obs, team_obs_plus}
for _, u := range users {
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
}
// --------------------------------------------------------------------
// Global obs/obs+ should not be able to see any team secrets
// --------------------------------------------------------------------
for _, u := range []*fleet.User{global_obs, global_obs_plus} {
s.setTokenForTest(t, u.Email, test.GoodPassword)
// list all teams
var listResp listTeamsResponse
s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Teams, len(teams))
require.NoError(t, listResp.Err)
for _, team := range listResp.Teams {
for _, secret := range team.Secrets {
require.Equal(t, fleet.MaskedPassword, secret.Secret)
}
}
// listing a team / team secrets
for _, team := range teams {
var getResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &getResp)
require.NoError(t, getResp.Err)
for _, secret := range getResp.Team.Secrets {
require.Equal(t, fleet.MaskedPassword, secret.Secret)
}
var secResp teamEnrollSecretsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), nil, http.StatusOK, &secResp)
require.Len(t, secResp.Secrets, 1)
require.NoError(t, secResp.Err)
for _, secret := range secResp.Secrets {
require.Equal(t, fleet.MaskedPassword, secret.Secret)
}
}
}
// --------------------------------------------------------------------
// Team obs/obs+ should not be able to see their team secrets
// --------------------------------------------------------------------
for _, u := range []*fleet.User{team_obs, team_obs_plus} {
s.setTokenForTest(t, u.Email, test.GoodPassword)
// list all teams
var listResp listTeamsResponse
s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Teams, len(u.Teams))
require.NoError(t, listResp.Err)
for _, team := range listResp.Teams {
for _, secret := range team.Secrets {
// team_obs has RoleObserver in Team 1, and an RoleAdmin in Team 2
// so it should be able to see the secrets in Team 1
if u.ID == team_obs.ID {
require.Equal(t, fleet.MaskedPassword == secret.Secret, team.ID == teams[0].ID)
require.Equal(t, fleet.MaskedPassword != secret.Secret, team.ID == teams[1].ID)
}
// team_obs_plus should not be able to see any Team Secret
if u.ID == team_obs_plus.ID {
require.Equal(t, fleet.MaskedPassword == secret.Secret, team.ID == teams[1].ID)
require.Equal(t, fleet.MaskedPassword != secret.Secret, team.ID == teams[0].ID)
}
}
}
// listing a team / team secrets
for _, team := range teams {
var getResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &getResp)
require.NoError(t, getResp.Err)
// team_obs has RoleObserver in Team 1, and an RoleAdmin in Team 2
// so it should be able to see the secrets in Team 1
for _, secret := range getResp.Team.Secrets {
if u.ID == team_obs.ID {
require.Equal(t, fleet.MaskedPassword == secret.Secret, team.ID == teams[0].ID)
require.Equal(t, fleet.MaskedPassword != secret.Secret, team.ID == teams[1].ID)
}
if u.ID == team_obs_plus.ID {
require.Equal(t, fleet.MaskedPassword == secret.Secret, team.ID == teams[1].ID)
require.Equal(t, fleet.MaskedPassword != secret.Secret, team.ID == teams[0].ID)
}
}
var secResp teamEnrollSecretsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", team.ID), nil, http.StatusOK, &secResp)
require.Len(t, secResp.Secrets, 1)
require.NoError(t, secResp.Err)
for _, secret := range secResp.Secrets {
if u.ID == team_obs.ID {
require.Equal(t, fleet.MaskedPassword == secret.Secret, team.ID == teams[0].ID)
require.Equal(t, fleet.MaskedPassword != secret.Secret, team.ID == teams[1].ID)
}
if u.ID == team_obs_plus.ID {
require.Equal(t, fleet.MaskedPassword == secret.Secret, team.ID == teams[1].ID)
require.Equal(t, fleet.MaskedPassword != secret.Secret, team.ID == teams[0].ID)
}
}
}
}
}
func (s *integrationEnterpriseTestSuite) TestExternalIntegrationsTeamConfig() {
t := s.T()
// create a test http server to act as the Jira and Zendesk server
srvURL := startExternalServiceWebServer(t)
// create a new team
team := &fleet.Team{
Name: t.Name(),
Description: "Team description",
Secrets: []*fleet.EnrollSecret{{Secret: "XYZ"}},
}
var tmResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &tmResp)
require.Equal(t, team.Name, tmResp.Team.Name)
require.Len(t, tmResp.Team.Secrets, 1)
require.Equal(t, "XYZ", tmResp.Team.Secrets[0].Secret)
team.ID = tmResp.Team.ID
// modify the team's config - enable the webhook
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "http://example.com",
},
HostStatusWebhook: &fleet.HostStatusWebhookSettings{
Enable: true,
DestinationURL: "http://example.com/host_status_webhook",
},
}}, http.StatusOK, &tmResp)
require.True(t, tmResp.Team.Config.WebhookSettings.FailingPoliciesWebhook.Enable)
require.Equal(t, "http://example.com", tmResp.Team.Config.WebhookSettings.FailingPoliciesWebhook.DestinationURL)
require.True(t, tmResp.Team.Config.WebhookSettings.HostStatusWebhook.Enable)
require.Equal(t, "http://example.com/host_status_webhook", tmResp.Team.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
// add an unknown automation - does not exist at the global level
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: false,
},
},
}}, http.StatusUnprocessableEntity, &tmResp)
// add a couple Jira integrations at the global level (qux and qux2)
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{
"integrations": {
"jira": [
{
"url": %q,
"username": "ok",
"api_token": "foo",
"project_key": "qux"
},
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux2"
}
]
}
}`, srvURL)), http.StatusOK)
// enable an automation - should fail as the webhook is enabled too
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
},
}}, http.StatusUnprocessableEntity, &tmResp)
// get the team, no integration was saved
var getResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &getResp)
require.Len(t, getResp.Team.Config.Integrations.Jira, 0)
require.Len(t, getResp.Team.Config.Integrations.Zendesk, 0)
// disable the webhook and enable the automation
tmResp = teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
},
},
WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: false,
DestinationURL: "http://example.com",
},
},
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Jira, 1)
require.Equal(t, "qux", tmResp.Team.Config.Integrations.Jira[0].ProjectKey)
// update the team with an unrelated field, should not change integrations
tmResp = teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Description: ptr.String("team-desc"),
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Jira, 1)
require.Equal(t, "team-desc", tmResp.Team.Description)
// make an unrelated appconfig change, should not remove the global integrations nor the teams'
var appCfgResp appConfigResponse
s.DoJSON("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
"org_info": {
"org_name": "test-integrations"
}
}`), http.StatusOK, &appCfgResp)
require.Equal(t, "test-integrations", appCfgResp.OrgInfo.OrgName)
require.Len(t, appCfgResp.Integrations.Jira, 2)
// enable the webhook without changing the integration should fail (an integration is already enabled)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "http://example.com",
},
}}, http.StatusUnprocessableEntity, &tmResp)
// add a second, disabled Jira integration
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
{
URL: srvURL,
ProjectKey: "qux2",
EnableFailingPolicies: false,
},
},
},
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Jira, 2)
require.Equal(t, "qux", tmResp.Team.Config.Integrations.Jira[0].ProjectKey)
require.Equal(t, "qux2", tmResp.Team.Config.Integrations.Jira[1].ProjectKey)
// enabling the second without disabling the first fails
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
{
URL: srvURL,
ProjectKey: "qux2",
EnableFailingPolicies: true,
},
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// updating to use the same project key fails (must be unique)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: false,
},
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// remove second integration, disable first so that nothing is enabled now
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: false,
},
},
},
}, http.StatusOK, &tmResp)
// enable the webhook now works
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "http://example.com",
},
}}, http.StatusOK, &tmResp)
// set environmental varible to use Zendesk test client
t.Setenv("TEST_ZENDESK_CLIENT", "true")
// add an unknown automation - does not exist at the global level
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: false,
},
},
}}, http.StatusUnprocessableEntity, &tmResp)
// add a couple Zendesk integrations at the global level (122 and 123), keep the jira ones too
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{
"integrations": {
"zendesk": [
{
"url": %q,
"email": "a@b.c",
"api_token": "ok",
"group_id": 122
},
{
"url": %[1]q,
"email": "b@b.c",
"api_token": "ok",
"group_id": 123
}
],
"jira": [
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux"
},
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux2"
}
]
}
}`, srvURL)), http.StatusOK)
// enable a Zendesk automation - should fail as the webhook is enabled too
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
},
}}, http.StatusUnprocessableEntity, &tmResp)
// disable the webhook and enable the automation
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
},
},
WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: false,
DestinationURL: "http://example.com",
},
},
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Zendesk, 1)
require.Equal(t, int64(122), tmResp.Team.Config.Integrations.Zendesk[0].GroupID)
// enable the webhook without changing the integration should fail (an integration is already enabled)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "http://example.com",
},
}}, http.StatusUnprocessableEntity, &tmResp)
// add a second, disabled Zendesk integration
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
{
URL: srvURL,
GroupID: 123,
EnableFailingPolicies: false,
},
},
},
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Zendesk, 2)
require.Equal(t, int64(122), tmResp.Team.Config.Integrations.Zendesk[0].GroupID)
require.Equal(t, int64(123), tmResp.Team.Config.Integrations.Zendesk[1].GroupID)
// update the team with an unrelated field, should not change integrations
tmResp = teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Description: ptr.String("team-desc-2"),
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Zendesk, 2)
require.Equal(t, "team-desc-2", tmResp.Team.Description)
// make an unrelated appconfig change, should not remove the global integrations nor the teams'
appCfgResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
"org_info": {
"org_name": "test-integrations-2"
}
}`), http.StatusOK, &appCfgResp)
require.Equal(t, "test-integrations-2", appCfgResp.OrgInfo.OrgName)
require.Len(t, appCfgResp.Integrations.Zendesk, 2)
require.Len(t, appCfgResp.Integrations.Jira, 2)
// enabling the second without disabling the first fails
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
{
URL: srvURL,
GroupID: 123,
EnableFailingPolicies: true,
},
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// updating to use the same group ID fails (must be unique per group ID)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 123,
EnableFailingPolicies: true,
},
{
URL: srvURL,
GroupID: 123,
EnableFailingPolicies: false,
},
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// remove second Zendesk integration, add disabled Jira integration
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
},
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: false,
},
},
},
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Jira, 1)
require.Equal(t, "qux", tmResp.Team.Config.Integrations.Jira[0].ProjectKey)
require.Len(t, tmResp.Team.Config.Integrations.Zendesk, 1)
require.Equal(t, int64(122), tmResp.Team.Config.Integrations.Zendesk[0].GroupID)
// enabling a Jira integration when a Zendesk one is enabled fails
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
},
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// set additional integrations on the team
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 122,
EnableFailingPolicies: true,
},
{
URL: srvURL,
GroupID: 123,
EnableFailingPolicies: false,
},
},
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: false,
},
},
},
}, http.StatusOK, &tmResp)
// removing Zendesk 122 from the global config removes it from the team too
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{
"integrations": {
"zendesk": [
{
"url": %[1]q,
"email": "b@b.c",
"api_token": "ok",
"group_id": 123
}
],
"jira": [
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux"
},
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux2"
}
]
}
}`, srvURL)), http.StatusOK)
// get the team, only one Zendesk integration remains, none are enabled
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &getResp)
require.Len(t, getResp.Team.Config.Integrations.Jira, 1)
require.Equal(t, "qux", getResp.Team.Config.Integrations.Jira[0].ProjectKey)
require.False(t, getResp.Team.Config.Integrations.Jira[0].EnableFailingPolicies)
require.Len(t, getResp.Team.Config.Integrations.Zendesk, 1)
require.Equal(t, int64(123), getResp.Team.Config.Integrations.Zendesk[0].GroupID)
require.False(t, getResp.Team.Config.Integrations.Zendesk[0].EnableFailingPolicies)
// removing Jira qux2 from the global config does not impact the team as it is unused.
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{
"integrations": {
"zendesk": [
{
"url": %[1]q,
"email": "b@b.c",
"api_token": "ok",
"group_id": 123
}
],
"jira": [
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux"
}
]
}
}`, srvURL)), http.StatusOK)
// get the team, integrations are unchanged
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &getResp)
require.Len(t, getResp.Team.Config.Integrations.Jira, 1)
require.Equal(t, "qux", getResp.Team.Config.Integrations.Jira[0].ProjectKey)
require.False(t, getResp.Team.Config.Integrations.Jira[0].EnableFailingPolicies)
require.Len(t, getResp.Team.Config.Integrations.Zendesk, 1)
require.Equal(t, int64(123), getResp.Team.Config.Integrations.Zendesk[0].GroupID)
require.False(t, getResp.Team.Config.Integrations.Zendesk[0].EnableFailingPolicies)
// enable Jira qux for the team
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{
{
URL: srvURL,
GroupID: 123,
EnableFailingPolicies: false,
},
},
Jira: []*fleet.TeamJiraIntegration{
{
URL: srvURL,
ProjectKey: "qux",
EnableFailingPolicies: true,
},
},
},
}, http.StatusOK, &tmResp)
// removing Zendesk 123 from the global config removes it from the team but
// leaves the Jira integration enabled.
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf(`{
"integrations": {
"jira": [
{
"url": %[1]q,
"username": "ok",
"api_token": "foo",
"project_key": "qux"
}
]
}
}`, srvURL)), http.StatusOK)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &getResp)
require.Len(t, getResp.Team.Config.Integrations.Jira, 1)
require.Equal(t, "qux", getResp.Team.Config.Integrations.Jira[0].ProjectKey)
require.True(t, getResp.Team.Config.Integrations.Jira[0].EnableFailingPolicies)
require.Len(t, getResp.Team.Config.Integrations.Zendesk, 0)
// remove all integrations on exit, so that other tests can enable the
// webhook as needed
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Integrations: &fleet.TeamIntegrations{
Zendesk: []*fleet.TeamZendeskIntegration{},
Jira: []*fleet.TeamJiraIntegration{},
},
WebhookSettings: &fleet.TeamWebhookSettings{},
}, http.StatusOK, &tmResp)
require.Len(t, tmResp.Team.Config.Integrations.Jira, 0)
require.Len(t, tmResp.Team.Config.Integrations.Zendesk, 0)
require.False(t, tmResp.Team.Config.WebhookSettings.FailingPoliciesWebhook.Enable)
require.Empty(t, tmResp.Team.Config.WebhookSettings.FailingPoliciesWebhook.DestinationURL)
appCfgResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
"integrations": {
"jira": [],
"zendesk": []
}
}`), http.StatusOK, &appCfgResp)
require.Len(t, appCfgResp.Integrations.Jira, 0)
require.Len(t, appCfgResp.Integrations.Zendesk, 0)
}
func (s *integrationEnterpriseTestSuite) TestNoTeamWebhookConfig() {
t := s.T()
// Test that we can configure webhooks for "No Team" (team ID 0)
// Use a generic response that will work with DefaultTeam
var defaultTeamResp struct {
Team *fleet.DefaultTeam `json:"team"`
}
// First clear any existing webhook configuration for "No Team"
s.DoJSON("PATCH", "/api/latest/fleet/teams/0", fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: false,
},
}}, http.StatusOK, &defaultTeamResp)
// Get the default team config (team ID 0) - should be disabled now
s.DoJSON("GET", "/api/latest/fleet/teams/0", nil, http.StatusOK, &defaultTeamResp)
require.Equal(t, uint(0), defaultTeamResp.Team.ID)
require.Equal(t, fleet.ReservedNameNoTeam, defaultTeamResp.Team.Name)
require.False(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
// Configure webhook settings for "No Team"
s.DoJSON("PATCH", "/api/latest/fleet/teams/0", fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "https://example.com/no-team-webhook",
PolicyIDs: []uint{1, 2, 3},
HostBatchSize: 100,
},
}}, http.StatusOK, &defaultTeamResp)
require.Equal(t, uint(0), defaultTeamResp.Team.ID)
require.Equal(t, fleet.ReservedNameNoTeam, defaultTeamResp.Team.Name)
require.True(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
require.Equal(t, "https://example.com/no-team-webhook", defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.DestinationURL)
require.Equal(t, []uint{1, 2, 3}, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.PolicyIDs)
require.Equal(t, 100, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.HostBatchSize)
// Get the config again to verify it persisted
defaultTeamResp = struct {
Team *fleet.DefaultTeam `json:"team"`
}{}
s.DoJSON("GET", "/api/latest/fleet/teams/0", nil, http.StatusOK, &defaultTeamResp)
require.Equal(t, uint(0), defaultTeamResp.Team.ID)
require.Equal(t, fleet.ReservedNameNoTeam, defaultTeamResp.Team.Name)
require.True(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
require.Equal(t, "https://example.com/no-team-webhook", defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.DestinationURL)
require.Equal(t, []uint{1, 2, 3}, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.PolicyIDs)
require.Equal(t, 100, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.HostBatchSize)
// Update the webhook settings
s.DoJSON("PATCH", "/api/latest/fleet/teams/0", fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: false,
DestinationURL: "https://example.com/updated",
PolicyIDs: []uint{4, 5},
HostBatchSize: 200,
},
}}, http.StatusOK, &defaultTeamResp)
require.False(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
require.Equal(t, "https://example.com/updated", defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.DestinationURL)
require.Equal(t, []uint{4, 5}, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.PolicyIDs)
require.Equal(t, 200, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.HostBatchSize)
// Clear the webhook settings
s.DoJSON("PATCH", "/api/latest/fleet/teams/0", fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: false,
},
}}, http.StatusOK, &defaultTeamResp)
require.False(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
}
func (s *integrationEnterpriseTestSuite) TestNoTeamFailingPolicyWebhookTrigger() {
t := s.T()
ctx := t.Context()
// Track webhook calls
webhookCalled := false
var capturedPolicy *fleet.Policy
// Create a host without a team (team_id = nil means no team)
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("no-team-host-key"),
UUID: "no-team-host-uuid",
Hostname: "no-team-host",
PrimaryIP: "192.168.1.100",
PrimaryMac: "00:11:22:33:44:55",
Platform: "ubuntu",
OSVersion: "Ubuntu 20.04",
Build: "",
PlatformLike: "debian",
OsqueryVersion: "5.5.0",
TeamID: nil, // nil means "No Team"
})
require.NoError(t, err)
require.Nil(t, host.TeamID)
// Create "No Team" policies (team_id = 0)
noTeamPol1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "no-team-failing-policy-1",
Query: "SELECT 1 WHERE 0", // Will always fail
Description: "Policy 1 for hosts without a team",
Resolution: "Fix policy 1",
})
require.NoError(t, err)
require.NotNil(t, noTeamPol1.TeamID)
require.Equal(t, uint(0), *noTeamPol1.TeamID)
noTeamPol2, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "no-team-failing-policy-2",
Query: "SELECT 1 WHERE 0", // Will also fail
Description: "Policy 2 for hosts without a team",
Resolution: "Fix policy 2",
})
require.NoError(t, err)
noTeamPol3, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "no-team-failing-policy-3",
Query: "SELECT 1 WHERE 0", // Will also fail (not in webhook config)
})
require.NoError(t, err)
// Configure webhook for "No Team" - only include pol1 and pol2
var defaultTeamResp struct {
Team *fleet.DefaultTeam `json:"team"`
}
s.DoJSON("PATCH", "/api/latest/fleet/teams/0", fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "https://example.com/webhook",
PolicyIDs: []uint{noTeamPol1.ID, noTeamPol2.ID}, // pol3 is NOT included
HostBatchSize: 100,
},
}}, http.StatusOK, &defaultTeamResp)
require.True(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
// Record policy results - all fail
err = s.ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{
noTeamPol1.ID: ptr.Bool(false), // Fails and is in webhook config
noTeamPol2.ID: ptr.Bool(false), // Fails and is in webhook config
noTeamPol3.ID: ptr.Bool(false), // Fails but NOT in webhook config
}, time.Now(), false)
require.NoError(t, err)
// Initially, OutdatedAutomationBatch should be empty (policies haven't been triggered for automation yet)
pfs, err := s.ds.OutdatedAutomationBatch(ctx)
require.NoError(t, err)
require.Empty(t, pfs, "initially no policies should be marked for automation")
// Reset with empty arrays - should not mark any policies
var resetResp struct{}
s.DoJSON("POST", "/api/latest/fleet/automations/reset", resetAutomationRequest{
TeamIDs: nil,
PolicyIDs: []uint{},
}, http.StatusOK, &resetResp)
pfs, err = s.ds.OutdatedAutomationBatch(ctx)
require.NoError(t, err)
require.Empty(t, pfs, "empty reset should not mark any policies")
// Test that we can configure and retrieve the "No Team" webhook settings
defaultTeamResp = struct {
Team *fleet.DefaultTeam `json:"team"`
}{}
s.DoJSON("GET", "/api/latest/fleet/teams/0", nil, http.StatusOK, &defaultTeamResp)
require.Equal(t, uint(0), defaultTeamResp.Team.ID)
require.Equal(t, fleet.ReservedNameNoTeam, defaultTeamResp.Team.Name)
require.True(t, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.Enable)
require.Equal(t, "https://example.com/webhook", defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.DestinationURL)
require.Equal(t, []uint{noTeamPol1.ID, noTeamPol2.ID}, defaultTeamResp.Team.WebhookSettings.FailingPoliciesWebhook.PolicyIDs)
// Now reset by team ID 0 to mark policies for automation
s.DoJSON("POST", "/api/latest/fleet/automations/reset", resetAutomationRequest{
TeamIDs: []uint{0}, // Team ID 0 for "No Team"
PolicyIDs: nil,
}, http.StatusOK, &resetResp)
// After reset, policies should be marked for automation
pfs, err = s.ds.OutdatedAutomationBatch(ctx)
require.NoError(t, err)
require.Len(t, pfs, 2, "should have 2 policies marked for automation (pol1 and pol2, not pol3)")
// Create a failing policy set and trigger the webhook
failingPolicySet := NewMemFailingPolicySet()
for _, pf := range pfs {
err = failingPolicySet.AddHost(pf.PolicyID, pf.Host)
require.NoError(t, err)
}
// Trigger the webhook automation with a custom sendFunc that captures the call
err = policies.TriggerFailingPoliciesAutomation(ctx, s.ds, kitlog.NewNopLogger(), failingPolicySet,
func(pol *fleet.Policy, cfg policies.FailingPolicyAutomationConfig) error {
webhookCalled = true
capturedPolicy = pol
// Verify the config is correct
require.NotNil(t, cfg.WebhookURL)
require.Equal(t, "https://example.com/webhook", cfg.WebhookURL.String())
require.Equal(t, 100, cfg.HostBatchSize)
return nil
})
require.NoError(t, err)
// Verify the webhook was called
require.True(t, webhookCalled, "webhook should have been called for No Team failing policy")
// Verify the captured policy is one of the configured policies
require.NotNil(t, capturedPolicy)
require.Contains(t, []string{"no-team-failing-policy-1", "no-team-failing-policy-2"}, capturedPolicy.Name)
// Verify the policy has team_id = 0 for "No Team"
require.NotNil(t, capturedPolicy.TeamID)
require.Equal(t, uint(0), *capturedPolicy.TeamID, "team_id should be 0 for No Team")
// Verify the failing policy set has the correct host
hosts, err := failingPolicySet.ListHosts(capturedPolicy.ID)
require.NoError(t, err)
require.Len(t, hosts, 1, "should have one host")
require.Equal(t, "no-team-host", hosts[0].Hostname)
}
func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() {
t := s.T()
// Create a team
team := &fleet.Team{
Name: t.Name(),
Description: "Team description",
Secrets: []*fleet.EnrollSecret{{Secret: "XYZ"}},
}
var tmResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &tmResp)
require.Equal(t, team.Name, tmResp.Team.Name)
team.ID = tmResp.Team.ID
checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, nil)
// modify the team's config
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(5),
GracePeriodDays: optjson.SetInt(2),
},
},
}, http.StatusOK, &tmResp)
require.Equal(t, 5, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "deadline_days": 5, "grace_period_days": 2}`, team.ID, team.Name), 0)
checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(5),
GracePeriodDays: optjson.SetInt(2),
})
// get the team via the GET endpoint, check that it properly returns the mdm
// settings.
var getTmResp getTeamResponse
s.DoJSON("GET", "/api/latest/fleet/teams/"+fmt.Sprint(team.ID), nil, http.StatusOK, &getTmResp)
require.Equal(t, fleet.TeamMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.String{Set: true},
Deadline: optjson.String{Set: true},
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.String{Set: true},
Deadline: optjson.String{Set: true},
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.String{Set: true},
Deadline: optjson.String{Set: true},
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(5),
GracePeriodDays: optjson.SetInt(2),
},
MacOSSetup: fleet.MacOSSetup{
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
Script: optjson.String{Set: true},
Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
ManualAgentInstall: optjson.Bool{Set: true},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
AndroidSettings: fleet.AndroidSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, getTmResp.Team.Config.MDM)
// only update the deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(6),
GracePeriodDays: optjson.SetInt(2),
},
},
}, http.StatusOK, &tmResp)
require.Equal(t, 6, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
lastActivity := s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "deadline_days": 6, "grace_period_days": 2}`, team.ID, team.Name), 0)
checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(6),
GracePeriodDays: optjson.SetInt(2),
})
// setting the macos updates doesn't alter the windows updates
tmResp = teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"),
},
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, 6, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
// did not create a new activity for windows updates
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), "", lastActivity)
lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), ``, 0)
checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(6),
GracePeriodDays: optjson.SetInt(2),
})
// sending a nil MDM or WindowsUpdates config doesn't modify anything
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": nil,
}, http.StatusOK, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": nil,
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, 6, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
// no new activity is created
s.lastActivityMatches("", "", lastActivity)
checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(6),
GracePeriodDays: optjson.SetInt(2),
})
// sending empty WindowsUpdates fields empties both fields
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": nil,
"grace_period_days": nil,
},
},
}, http.StatusOK, &tmResp)
require.False(t, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Valid)
require.False(t, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Valid)
s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "deadline_days": null, "grace_period_days": null}`, team.ID, team.Name), 0)
checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, nil)
// error checks:
// try to set an invalid deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": 1000,
"grace_period_days": 1,
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set an invalid grace period
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": 1,
"grace_period_days": 1000,
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set a deadline but not a grace period
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": 1,
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set a grace period but no deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": map[string]any{
"grace_period_days": 1,
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set an empty grace period but a non-empty deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": map[string]any{
"deadline_days": 1,
"grace_period_days": nil,
},
},
}, http.StatusUnprocessableEntity, &tmResp)
}
func (s *integrationEnterpriseTestSuite) TestGitOpsModeConfig() {
t := s.T()
res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "gitops_mode_enabled": true, "repository_url": "" }
}`), http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, "Repository URL is required when GitOps mode is enabled")
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "gitops_mode_enabled": true, "repository_url": "a.b.cc" }
}`), http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, "Git repository URL must include protocol (e.g. https://)")
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "gitops_mode_enabled": true, "repository_url": "https://a.b.cc" }
}`), http.StatusOK)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledGitOpsMode{}.ActivityName(), "", 0)
// turn off, persists repo url
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"gitops": { "gitops_mode_enabled": false, "repository_url": "https://a.b.cc" }
}`), http.StatusOK)
s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledGitOpsMode{}.ActivityName(), "", 0)
// Make sure URL isn't cleared after another setting is changed
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"org_info": {"org_name": "Changes"}
}`), http.StatusOK)
config, err := s.ds.AppConfig(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://a.b.cc", config.UIGitOpsMode.RepositoryURL)
}
func (s *integrationEnterpriseTestSuite) assertAppleOSUpdatesDeclaration(teamID *uint, profileName string, expected *fleet.AppleOSUpdateSettings) {
t := s.T()
if teamID == nil {
teamID = ptr.Uint(0)
}
var declUUID string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
err := sqlx.GetContext(context.Background(), q, &declUUID,
`SELECT declaration_uuid FROM mdm_apple_declarations WHERE team_id = ? AND name = ?`, teamID, profileName)
if expected == nil {
require.Error(t, err)
return nil
}
return err
})
if expected == nil {
// we already validated that the declaration did not exist
return
}
decl, err := s.ds.GetMDMAppleDeclaration(context.Background(), declUUID)
require.NoError(t, err)
require.Contains(t, string(decl.RawJSON), fmt.Sprintf(`"TargetOSVersion": "%s"`, expected.MinimumVersion.Value))
require.Contains(t, string(decl.RawJSON), fmt.Sprintf(`"TargetLocalDateTime": "%sT12:00:00"`, expected.Deadline.Value))
}
func (s *integrationEnterpriseTestSuite) TestAppleOSUpdatesTeamConfig() {
t := s.T()
team := &fleet.Team{
Name: t.Name(),
Description: "Team description",
Secrets: []*fleet.EnrollSecret{{Secret: "XYZ"}},
}
var tmResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &tmResp)
require.Equal(t, team.Name, tmResp.Team.Name)
team.ID = tmResp.Team.ID
// no OS updates settings at the moment
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, nil)
// modify the team's config (macOS first)
macOSUpdates := &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"),
}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": macOSUpdates,
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2021-01-01"}`, team.ID, team.Name), 0)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, nil)
// modify the team's config (now iOS and iPadOS)
iOSUpdates := &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("11.11.11"),
Deadline: optjson.SetString("2022-02-02"),
}
iPadOSUpdates := &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("12.12.12"),
Deadline: optjson.SetString("2023-03-03"),
}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": iOSUpdates,
"ipados_updates": iPadOSUpdates,
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2022-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2023-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2021-01-01"}`, team.ID, team.Name), 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "11.11.11", "deadline": "2022-02-02"}`, team.ID, team.Name), 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "12.12.12", "deadline": "2023-03-03"}`, team.ID, team.Name), 0)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates)
// only update the deadlines
macOSUpdates = &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2025-10-01"),
}
iOSUpdates = &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("11.11.11"),
Deadline: optjson.SetString("2024-02-02"),
}
iPadOSUpdates = &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("12.12.12"),
Deadline: optjson.SetString("2024-03-03"),
}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": macOSUpdates,
"ios_updates": iOSUpdates,
"ipados_updates": iPadOSUpdates,
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value)
macOSLastActivity := s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2025-10-01"}`, team.ID, team.Name), 0)
iOSLastActivity := s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "11.11.11", "deadline": "2024-02-02"}`, team.ID, team.Name), 0)
iPadOSLastActivity := s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "12.12.12", "deadline": "2024-03-03"}`, team.ID, team.Name), 0)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates)
// setting the windows updates doesn't alter the apple updates
tmResp = teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"windows_updates": &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(10),
GracePeriodDays: optjson.SetInt(2),
},
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value)
require.Equal(t, 10, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
// did not create a new activity for os updates
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), "", macOSLastActivity)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), "", iOSLastActivity)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), "", iPadOSLastActivity)
lastActivity := s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), ``, 0)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates)
// sending a nil MDM or MacOSUpdate config doesn't modify anything
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": nil,
}, http.StatusOK, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": nil,
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value)
// no new activity is created
s.lastActivityMatches("", "", lastActivity)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates)
// sending macos settings but no macos_updates does not change the macos updates
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_settings": map[string]any{
"custom_settings": nil,
},
},
}, http.StatusOK, &tmResp)
require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
// no new activity is created
s.lastActivityMatches("", "", lastActivity)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates)
// sending empty apple os updates fields empties both fields and removes the DDM profiles
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"minimum_version": "",
"deadline": nil,
},
"ios_updates": map[string]any{
"minimum_version": "",
"deadline": nil,
},
"ipados_updates": map[string]any{
"minimum_version": "",
"deadline": nil,
},
},
}, http.StatusOK, &tmResp)
require.Empty(t, tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Empty(t, tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Empty(t, tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value)
require.Empty(t, tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value)
require.Empty(t, tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Empty(t, tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, nil)
// error checks:
// try to set an invalid deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"minimum_version": "10.15.0",
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": map[string]any{
"minimum_version": "10.15.0",
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ipados_updates": map[string]any{
"minimum_version": "10.15.0",
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set an invalid minimum version
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"minimum_version": "10.15.0 (19A583)",
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": map[string]any{
"minimum_version": "10.15.0 (19A583)",
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ipados_updates": map[string]any{
"minimum_version": "10.15.0 (19A583)",
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set a deadline but not a minimum version
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": map[string]any{
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ipados_updates": map[string]any{
"deadline": "2021-01-01T00:00:00Z",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set an empty deadline but not a minimum version
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"deadline": "",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": map[string]any{
"deadline": "",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ipados_updates": map[string]any{
"deadline": "",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set a minimum version but not a deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"minimum_version": "10.15.0 (19A583)",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": map[string]any{
"minimum_version": "10.15.0 (19A583)",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ipados_updates": map[string]any{
"minimum_version": "10.15.0 (19A583)",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
// try to set an empty minimum version but not a deadline
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"macos_updates": map[string]any{
"minimum_version": "",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ios_updates": map[string]any{
"minimum_version": "",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": map[string]any{
"ipados_updates": map[string]any{
"minimum_version": "",
},
},
}, http.StatusUnprocessableEntity, &tmResp)
}
func (s *integrationEnterpriseTestSuite) TestLinuxDiskEncryption() {
t := s.T()
// create a Linux host
noTeamHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "3"),
OsqueryHostID: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "3"),
UUID: t.Name() + "3",
Hostname: t.Name() + "foo3.local",
PrimaryIP: "192.168.1.3",
PrimaryMac: "30-65-EC-6F-C4-60",
Platform: "ubuntu",
OSVersion: "Ubuntu 22.04",
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, noTeamHost, s.ds)
noTeamHost.OrbitNodeKey = &orbitKey
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "A team"})
require.NoError(t, err)
teamID := ptr.Uint(team.ID)
teamHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "2"),
OsqueryHostID: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "2"),
UUID: t.Name() + "2",
Hostname: t.Name() + "foo2.local",
PrimaryIP: "192.168.1.2",
PrimaryMac: "30-65-EC-6F-C4-59",
Platform: "rhel",
OSVersion: "Fedora 38.0", // this check is why HostLite now includes os_version in the data it's selecting
TeamID: teamID,
})
require.NoError(t, err)
teamOrbitKey := setOrbitEnrollment(t, teamHost, s.ds)
teamHost.OrbitNodeKey = &teamOrbitKey
// NO TEAM //
// config profiles endpoint should work but show all zeroes
var profileSummary getMDMProfilesSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// set encrypted for host
require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), noTeamHost.ID, true))
// should still show zeroes
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// should be nil before disk encryption is turned on
// from host details
getHostResp := getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", noTeamHost.ID), nil, http.StatusOK, &getHostResp)
require.Nil(t, getHostResp.Host.MDM.OSSettings)
// and my device
deviceToken := "for_sure_secure"
createDeviceTokenForHost(t, s.ds, noTeamHost.ID, deviceToken)
getDeviceHostResp := getDeviceHostResponse{}
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+deviceToken, nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp)
require.NoError(t, err)
require.Nil(t, getHostResp.Host.MDM.OSSettings)
// turn on disk encryption enforcement
s.Do("POST", "/api/latest/fleet/disk_encryption", updateDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent)
// should be populated after disk encryption is turned on
// from host details
getHostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", noTeamHost.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.OSSettings)
// and my device
getDeviceHostResp = getDeviceHostResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+deviceToken, nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp)
require.NoError(t, err)
require.NotNil(t, getHostResp.Host.MDM.OSSettings)
// should show the Linux host as pending
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{Pending: 1}, profileSummary.MDMProfilesSummary)
// encryption summary should succeed (Linux encryption doesn't require MDM)
var summary getMDMDiskEncryptionSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryRequest{}, http.StatusOK, &summary)
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{}, http.StatusOK, &summary)
// disk is encrypted but key hasn't been escrowed yet
require.Equal(t, fleet.MDMDiskEncryptionSummary{ActionRequired: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// trigger escrow process from device
// should fail because default Orbit version is too old
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/mdm/linux/trigger_escrow", deviceToken), nil, http.StatusBadRequest)
res.Body.Close()
// should succeed now that Orbit version isn't too old
require.NoError(t, s.ds.SetOrUpdateHostOrbitInfo(context.Background(), noTeamHost.ID, fleet.MinOrbitLUKSVersion, sql.NullString{}, sql.NullBool{}))
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/mdm/linux/trigger_escrow", deviceToken), nil, http.StatusNoContent)
res.Body.Close()
// confirm that Orbit endpoint shows notification flag
var orbitResponse orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config", orbitGetConfigRequest{OrbitNodeKey: orbitKey}, http.StatusOK, &orbitResponse)
require.True(t, orbitResponse.Notifications.RunDiskEncryptionEscrow)
// confirm that second Orbit pull doesn't show notification flag
var secondOrbitResponse orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config", orbitGetConfigRequest{OrbitNodeKey: orbitKey}, http.StatusOK, &secondOrbitResponse)
require.False(t, secondOrbitResponse.Notifications.RunDiskEncryptionEscrow)
// set an error first; the successful write should overwrite that
s.Do("POST", "/api/fleet/orbit/luks_data", orbitPostLUKSRequest{
OrbitNodeKey: *noTeamHost.OrbitNodeKey,
ClientError: "Houston, we had a problem",
}, http.StatusNoContent)
// upload LUKS data
keySlot := ptr.Uint(1)
s.Do("POST", "/api/fleet/orbit/luks_data", orbitPostLUKSRequest{
OrbitNodeKey: *noTeamHost.OrbitNodeKey,
Passphrase: "whale makes pail rise",
Salt: "the team i like lost",
KeySlot: keySlot,
}, http.StatusNoContent)
// confirm verified
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{}, http.StatusOK, &summary)
require.Equal(t, fleet.MDMDiskEncryptionSummary{Verified: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// get passphrase back
var keyResponse getHostEncryptionKeyResponse
s.DoJSON("GET", fmt.Sprintf(`/api/latest/fleet/mdm/hosts/%d/encryption_key`, noTeamHost.ID), getHostEncryptionKeyRequest{}, http.StatusOK, &keyResponse)
s.DoJSON("GET", fmt.Sprintf(`/api/latest/fleet/hosts/%d/encryption_key`, noTeamHost.ID), getHostEncryptionKeyRequest{}, http.StatusOK, &keyResponse)
require.Equal(t, "whale makes pail rise", keyResponse.EncryptionKey.DecryptedValue)
// TEAM //
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{TeamID: teamID}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// set encrypted for host
require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), teamHost.ID, true))
// should still show zeroes
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{TeamID: teamID}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// turn on disk encryption enforcement for team
s.Do("POST", "/api/latest/fleet/disk_encryption", updateDiskEncryptionRequest{TeamID: teamID, EnableDiskEncryption: true}, http.StatusNoContent)
// should show the Linux host as pending
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{TeamID: teamID}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{Pending: 1}, profileSummary.MDMProfilesSummary)
// encryption summary should show host as action required
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{TeamID: teamID}, http.StatusOK, &summary)
require.Equal(t, fleet.MDMDiskEncryptionSummary{ActionRequired: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// upload LUKS data (no error, and no trigger, first this time)
keySlot = ptr.Uint(3)
s.Do("POST", "/api/fleet/orbit/luks_data", orbitPostLUKSRequest{
OrbitNodeKey: *teamHost.OrbitNodeKey,
Passphrase: "the mome raths outgrabe",
Salt: "jabberwocky, but salty",
KeySlot: keySlot,
}, http.StatusNoContent)
// confirm verified
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{TeamID: teamID}, http.StatusOK, &summary)
require.Equal(t, fleet.MDMDiskEncryptionSummary{Verified: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// get passphrase back
s.DoJSON("GET", fmt.Sprintf(`/api/latest/fleet/hosts/%d/encryption_key`, teamHost.ID), getHostEncryptionKeyRequest{}, http.StatusOK, &keyResponse)
require.Equal(t, "the mome raths outgrabe", keyResponse.EncryptionKey.DecryptedValue)
}
func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
t := s.T()
ctx := context.Background()
// set the logo via the modify appconfig endpoint, so that the cache is
// properly updated.
var acResp appConfigResponse
s.DoJSON("PATCH", "/api/latest/fleet/config",
json.RawMessage(`{
"org_info":{
"org_logo_url": "http://example.com/logo",
"contact_url": "http://example.com/contact"
}
}`), http.StatusOK, &acResp)
require.Equal(t, "http://example.com/logo", acResp.OrgInfo.OrgLogoURL)
require.Equal(t, "http://example.com/contact", acResp.OrgInfo.ContactURL)
team, err := s.ds.NewTeam(ctx, &fleet.Team{
ID: 51,
Name: "team1-policies",
Description: "desc team1",
})
require.NoError(t, err)
token := "much_valid"
host := createHostAndDeviceToken(t, s.ds, token)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
require.NoError(t, err)
qr, err := s.ds.NewQuery(ctx, &fleet.Query{
Name: "TestQueryEnterpriseGlobalPolicy",
Description: "Some description",
Query: "select * from osquery;",
ObserverCanRun: true,
Logging: fleet.LoggingSnapshot,
})
require.NoError(t, err)
// add a global policy
gpParams := globalPolicyRequest{
QueryID: &qr.ID,
Resolution: "some global resolution",
}
gpResp := globalPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/policies", gpParams, http.StatusOK, &gpResp)
require.NotNil(t, gpResp.Policy)
// add a policy execution
require.NoError(t, s.ds.RecordPolicyQueryExecutions(ctx, host,
map[uint]*bool{gpResp.Policy.ID: ptr.Bool(false)}, time.Now(), false))
// add a policy to team
oldToken := s.token
t.Cleanup(func() {
s.token = oldToken
})
password := test.GoodPassword
email := "test_enterprise_policies@user.com"
u := &fleet.User{
Name: "test team user",
Email: email,
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team,
Role: fleet.RoleMaintainer,
},
},
}
require.NoError(t, u.SetPassword(password, 10, 10))
_, err = s.ds.NewUser(ctx, u)
require.NoError(t, err)
s.token = s.getTestToken(email, password)
tpParams := teamPolicyRequest{
Name: "TestQueryEnterpriseTeamPolicy",
Query: "select * from osquery;",
Description: "Some description",
Resolution: "some team resolution",
Platform: "darwin",
}
tpResp := teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team.ID), tpParams, http.StatusOK, &tpResp)
// try with invalid token
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/policies", nil, http.StatusUnauthorized)
err = res.Body.Close()
require.NoError(t, err)
// GET `/api/_version_/fleet/device/{token}/policies`
listDevicePoliciesResp := listDevicePoliciesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/policies", nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&listDevicePoliciesResp)
require.NoError(t, err)
err = res.Body.Close()
require.NoError(t, err)
require.Len(t, listDevicePoliciesResp.Policies, 2)
require.NoError(t, listDevicePoliciesResp.Err)
// GET `/api/_version_/fleet/device/{token}`
getDeviceHostResp := getDeviceHostResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp)
require.NoError(t, err)
err = res.Body.Close()
require.NoError(t, err)
require.NoError(t, getDeviceHostResp.Err)
require.Equal(t, host.ID, getDeviceHostResp.Host.ID)
require.False(t, getDeviceHostResp.Host.RefetchRequested)
require.Equal(t, "http://example.com/logo", getDeviceHostResp.OrgLogoURL)
require.Equal(t, "http://example.com/contact", getDeviceHostResp.OrgContactURL)
require.Len(t, *getDeviceHostResp.Host.Policies, 2)
require.False(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory)
// GET `/api/_version_/fleet/device/{token}/desktop`
getDesktopResp := fleetDesktopResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDesktopResp)
require.NoError(t, err)
err = res.Body.Close()
require.NoError(t, err)
require.NoError(t, getDesktopResp.Err)
require.Equal(t, *getDesktopResp.FailingPolicies, uint(1))
require.False(t, getDesktopResp.Notifications.NeedsMDMMigration)
// update the team to enable software inventory
team.Config.Features.EnableSoftwareInventory = true
_, err = s.ds.SaveTeam(ctx, team)
require.NoError(t, err)
getDeviceHostResp = getDeviceHostResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp)
require.NoError(t, err)
require.True(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory)
}
// TestCustomTransparencyURL tests that Fleet Premium licensees can use custom transparency urls.
func (s *integrationEnterpriseTestSuite) TestCustomTransparencyURL() {
t := s.T()
token := "token_test_custom_transparency_url"
createHostAndDeviceToken(t, s.ds, token)
// confirm intitial default url
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Equal(t, fleet.DefaultTransparencyURL, acResp.FleetDesktop.TransparencyURL)
// confirm device endpoint returns initial default url
deviceResp := &transparencyURLResponse{}
rawResp := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/transparency", nil, http.StatusTemporaryRedirect)
json.NewDecoder(rawResp.Body).Decode(deviceResp) //nolint:errcheck
rawResp.Body.Close() //nolint:errcheck
require.NoError(t, deviceResp.Err)
require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location"))
// set custom url
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"fleet_desktop":{"transparency_url": "customURL"}}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Equal(t, "customURL", acResp.FleetDesktop.TransparencyURL)
// device endpoint returns custom url
deviceResp = &transparencyURLResponse{}
rawResp = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/transparency", nil, http.StatusTemporaryRedirect)
json.NewDecoder(rawResp.Body).Decode(deviceResp) //nolint:errcheck
rawResp.Body.Close() //nolint:errcheck
require.NoError(t, deviceResp.Err)
require.Equal(t, "customURL", rawResp.Header.Get("Location"))
// empty string applies default url
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"fleet_desktop":{"transparency_url": ""}}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Equal(t, fleet.DefaultTransparencyURL, acResp.FleetDesktop.TransparencyURL)
// device endpoint returns default url
deviceResp = &transparencyURLResponse{}
rawResp = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/transparency", nil, http.StatusTemporaryRedirect)
json.NewDecoder(rawResp.Body).Decode(deviceResp) //nolint:errcheck
rawResp.Body.Close() //nolint:errcheck
require.NoError(t, deviceResp.Err)
require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location"))
}
func (s *integrationEnterpriseTestSuite) TestMDMWindowsUpdates() {
t := s.T()
// keep the last activity, to detect newly created ones
var activitiesResp listActivitiesResponse
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp, "order_key", "a.id", "order_direction", "desc")
var lastActivity uint
if len(activitiesResp.Activities) > 0 {
lastActivity = activitiesResp.Activities[0].ID
}
checkInvalidConfig := func(config string) {
// try to set an invalid config
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(config), http.StatusUnprocessableEntity, &acResp)
// get the appconfig, nothing changed
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Equal(t, fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, acResp.MDM.WindowsUpdates)
// no activity got created
activitiesResp = listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp, "order_key", "a.id", "order_direction", "desc")
require.Condition(t, func() bool {
return (lastActivity == 0 && len(activitiesResp.Activities) == 0) ||
(len(activitiesResp.Activities) > 0 && activitiesResp.Activities[0].ID == lastActivity)
})
}
// missing grace period
checkInvalidConfig(`{"mdm": {
"windows_updates": {
"deadline_days": 1
}
}}`)
// missing deadline
checkInvalidConfig(`{"mdm": {
"windows_updates": {
"grace_period_days": 1
}
}}`)
// invalid deadline
checkInvalidConfig(`{"mdm": {
"windows_updates": {
"grace_period_days": 1,
"deadline_days": -1
}
}}`)
// invalid grace period
checkInvalidConfig(`{"mdm": {
"windows_updates": {
"grace_period_days": -1,
"deadline_days": 1
}
}}`)
checkWindowsOSUpdatesProfile(t, s.ds, nil, nil)
// valid config
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_updates": {
"deadline_days": 5,
"grace_period_days": 1
}
}
}`), http.StatusOK, &acResp)
require.Equal(t, 5, acResp.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value)
checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(5),
GracePeriodDays: optjson.SetInt(1),
})
// edited windows updates activity got created
s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), `{"deadline_days":5, "grace_period_days":1, "team_id": null, "team_name": null}`, 0)
// get the appconfig
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Equal(t, 5, acResp.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value)
// update the deadline
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_updates": {
"deadline_days": 6,
"grace_period_days": 1
}
}
}`), http.StatusOK, &acResp)
require.Equal(t, 6, acResp.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value)
checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(6),
GracePeriodDays: optjson.SetInt(1),
})
// another edited windows updates activity got created
lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), `{"deadline_days":6, "grace_period_days":1, "team_id": null, "team_name": null}`, 0)
// update something unrelated - the transparency url
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"fleet_desktop":{"transparency_url": "customURL"}}`), http.StatusOK, &acResp)
require.Equal(t, 6, acResp.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value)
// no activity got created
s.lastActivityMatches("", ``, lastActivity)
checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(6),
GracePeriodDays: optjson.SetInt(1),
})
// clear the Windows requirement
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_updates": {
"deadline_days": null,
"grace_period_days": null
}
}
}`), http.StatusOK, &acResp)
require.False(t, acResp.MDM.WindowsUpdates.DeadlineDays.Valid)
require.False(t, acResp.MDM.WindowsUpdates.GracePeriodDays.Valid)
// edited windows updates activity got created with empty requirement
lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), `{"deadline_days":null, "grace_period_days":null, "team_id": null, "team_name": null}`, 0)
checkWindowsOSUpdatesProfile(t, s.ds, nil, nil)
// update again with empty windows requirement
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_updates": {
"deadline_days": null,
"grace_period_days": null
}
}
}`), http.StatusOK, &acResp)
require.False(t, acResp.MDM.WindowsUpdates.DeadlineDays.Valid)
require.False(t, acResp.MDM.WindowsUpdates.GracePeriodDays.Valid)
// no activity got created
s.lastActivityMatches("", ``, lastActivity)
}
func (s *integrationEnterpriseTestSuite) TestMDMAppleOSUpdates() {
t := s.T()
// keep the last activity, to detect newly created ones
var activitiesResp listActivitiesResponse
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp, "order_key", "a.id", "order_direction", "desc")
var lastActivity uint
if len(activitiesResp.Activities) > 0 {
lastActivity = activitiesResp.Activities[0].ID
}
checkInvalidConfig := func(config string) {
// try to set an invalid config
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(config), http.StatusUnprocessableEntity, &acResp)
// get the appconfig, nothing changed
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Equal(t, fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, acResp.MDM.MacOSUpdates)
// no activity got created
activitiesResp = listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp, "order_key", "a.id", "order_direction", "desc")
require.Condition(t, func() bool {
return (lastActivity == 0 && len(activitiesResp.Activities) == 0) ||
(len(activitiesResp.Activities) > 0 && activitiesResp.Activities[0].ID == lastActivity)
})
}
// missing minimum_version
checkInvalidConfig(`{"mdm": {
"macos_updates": {
"deadline": "2022-01-01"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ios_updates": {
"deadline": "2022-01-01"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ipados_updates": {
"deadline": "2022-01-01"
}
}}`)
// missing deadline
checkInvalidConfig(`{"mdm": {
"macos_updates": {
"minimum_version": "12.1.1"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ios_updates": {
"minimum_version": "12.1.1"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ipados_updates": {
"minimum_version": "12.1.1"
}
}}`)
// invalid deadline
checkInvalidConfig(`{"mdm": {
"macos_updates": {
"minimum_version": "12.1.1",
"deadline": "2022"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ios_updates": {
"minimum_version": "12.1.1",
"deadline": "2022"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ipados_updates": {
"minimum_version": "12.1.1",
"deadline": "2022"
}
}}`)
// deadline includes timestamp
checkInvalidConfig(`{"mdm": {
"macos_updates": {
"minimum_version": "12.1.1",
"deadline": "2022-01-01T00:00:00Z"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ios_updates": {
"minimum_version": "12.1.1",
"deadline": "2022-01-01T00:00:00Z"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ipados_updates": {
"minimum_version": "12.1.1",
"deadline": "2022-01-01T00:00:00Z"
}
}}`)
// minimum_version includes build info
checkInvalidConfig(`{"mdm": {
"macos_updates": {
"minimum_version": "12.1.1 (ABCD)",
"deadline": "2022-01-01"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ios_updates": {
"minimum_version": "12.1.1 (ABCD)",
"deadline": "2022-01-01"
}
}}`)
checkInvalidConfig(`{"mdm": {
"ipados_updates": {
"minimum_version": "12.1.1 (ABCD)",
"deadline": "2022-01-01"
}
}}`)
// valid config
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": {
"minimum_version": "12.3.1",
"deadline": "2022-01-01"
},
"ios_updates": {
"minimum_version": "13.13.13",
"deadline": "2023-03-03"
},
"ipados_updates": {
"minimum_version": "14.14.14",
"deadline": "2024-04-04"
}
}
}`), http.StatusOK, &acResp)
require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2022-01-01", acResp.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "13.13.13", acResp.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2023-03-03", acResp.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "14.14.14", acResp.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-04-04", acResp.MDM.IPadOSUpdates.Deadline.Value)
// edited macos min version activity got created
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2022-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), `{"deadline":"2023-03-03", "minimum_version":"13.13.13", "team_id": null, "team_name": null}`, 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), `{"deadline":"2024-04-04", "minimum_version":"14.14.14", "team_id": null, "team_name": null}`, 0)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2022-01-01"),
})
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2023-03-03"),
})
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("14.14.14"), Deadline: optjson.SetString("2024-04-04"),
})
// get the appconfig
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2022-01-01", acResp.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "13.13.13", acResp.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2023-03-03", acResp.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "14.14.14", acResp.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-04-04", acResp.MDM.IPadOSUpdates.Deadline.Value)
// update the deadline
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": {
"minimum_version": "12.3.1",
"deadline": "2024-01-01"
},
"ios_updates": {
"minimum_version": "13.13.13",
"deadline": "2025-05-05"
},
"ipados_updates": {
"minimum_version": "14.14.14",
"deadline": "2026-06-06"
}
}
}`), http.StatusOK, &acResp)
require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-01-01", acResp.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "13.13.13", acResp.MDM.IOSUpdates.MinimumVersion.Value)
require.Equal(t, "2025-05-05", acResp.MDM.IOSUpdates.Deadline.Value)
require.Equal(t, "14.14.14", acResp.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Equal(t, "2026-06-06", acResp.MDM.IPadOSUpdates.Deadline.Value)
// another edited macos min version activity got created
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2024-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), `{"deadline":"2025-05-05", "minimum_version":"13.13.13", "team_id": null, "team_name": null}`, 0)
lastActivity = s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), `{"deadline":"2026-06-06", "minimum_version":"14.14.14", "team_id": null, "team_name": null}`, 0)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01"),
})
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2025-05-05"),
})
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("14.14.14"), Deadline: optjson.SetString("2026-06-06"),
})
// update something unrelated - the transparency url
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"fleet_desktop":{"transparency_url": "customURL"}}`), http.StatusOK, &acResp)
require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, "2024-01-01", acResp.MDM.MacOSUpdates.Deadline.Value)
// no activity got created
s.lastActivityMatches("", ``, lastActivity)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01"),
})
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2025-05-05"),
})
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("14.14.14"), Deadline: optjson.SetString("2026-06-06"),
})
// clear the apple OS requirements
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": {
"minimum_version": "",
"deadline": ""
},
"ios_updates": {
"minimum_version": "",
"deadline": ""
},
"ipados_updates": {
"minimum_version": "",
"deadline": ""
}
}
}`), http.StatusOK, &acResp)
require.Empty(t, acResp.MDM.MacOSUpdates.MinimumVersion.Value)
require.Empty(t, acResp.MDM.MacOSUpdates.Deadline.Value)
require.Empty(t, acResp.MDM.IOSUpdates.MinimumVersion.Value)
require.Empty(t, acResp.MDM.IOSUpdates.Deadline.Value)
require.Empty(t, acResp.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Empty(t, acResp.MDM.IPadOSUpdates.Deadline.Value)
// edited macos min version activity got created with empty requirement
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0)
lastActivity = s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0)
// check DDM profiles were removed
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, nil)
// update again with empty apple OS requirements
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": {
"minimum_version": "",
"deadline": ""
},
"ios_updates": {
"minimum_version": "",
"deadline": ""
},
"ipados_updates": {
"minimum_version": "",
"deadline": ""
}
}
}`), http.StatusOK, &acResp)
require.Empty(t, acResp.MDM.MacOSUpdates.MinimumVersion.Value)
require.Empty(t, acResp.MDM.MacOSUpdates.Deadline.Value)
require.Empty(t, acResp.MDM.IOSUpdates.MinimumVersion.Value)
require.Empty(t, acResp.MDM.IOSUpdates.Deadline.Value)
require.Empty(t, acResp.MDM.IPadOSUpdates.MinimumVersion.Value)
require.Empty(t, acResp.MDM.IPadOSUpdates.Deadline.Value)
// no activity or DDM profiles were created
s.lastActivityMatches("", ``, lastActivity)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, nil)
s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, nil)
}
// Skipping admin-created users because we don't have email fully set up in integration tests
func (s *integrationEnterpriseTestSuite) TestInvitedUserMFA() {
t := s.T()
// create valid invite
createInviteReq := createInviteRequest{InvitePayload: fleet.InvitePayload{
Email: ptr.String("some email"),
Name: ptr.String("some name"),
GlobalRole: null.StringFrom(fleet.RoleAdmin),
MFAEnabled: ptr.Bool(true),
SSOEnabled: ptr.Bool(true),
}}
createInviteResp := createInviteResponse{}
s.DoJSON("POST", "/api/latest/fleet/invites", createInviteReq, http.StatusConflict, &createInviteResp)
createInviteReq.SSOEnabled = nil
s.DoJSON("POST", "/api/latest/fleet/invites", createInviteReq, http.StatusOK, &createInviteResp)
require.NotNil(t, createInviteResp.Invite)
require.NotZero(t, createInviteResp.Invite.ID)
validInvite := *createInviteResp.Invite
// create user from valid invite - the token was not returned via the
// response's json, must get it from the db
inv, err := s.ds.Invite(context.Background(), validInvite.ID)
require.NoError(t, err)
validInviteToken := inv.Token
// verify the token with valid invite
var verifyInvResp verifyInviteResponse
s.DoJSON("GET", "/api/latest/fleet/invites/"+validInviteToken, nil, http.StatusOK, &verifyInvResp)
require.Equal(t, validInvite.ID, verifyInvResp.Invite.ID)
var createFromInviteResp createUserResponse
s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{
Name: ptr.String("Full Name"),
Password: ptr.String(test.GoodPassword),
Email: ptr.String("a@b.c"),
InviteToken: ptr.String(validInviteToken),
}, http.StatusOK, &createFromInviteResp)
require.True(t, createFromInviteResp.User.MFAEnabled)
// create an invite with SSO, swap to MFA
createInviteReq = createInviteRequest{InvitePayload: fleet.InvitePayload{
Email: ptr.String("a@b.d"),
Name: ptr.String("some other name"),
GlobalRole: null.StringFrom(fleet.RoleAdmin),
SSOEnabled: ptr.Bool(true),
}}
s.DoJSON("POST", "/api/latest/fleet/invites", createInviteReq, http.StatusOK, &createInviteResp)
validInvite = *createInviteResp.Invite
var updateInviteResp updateInviteResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/invites/%d", validInvite.ID), updateInviteRequest{
InvitePayload: fleet.InvitePayload{MFAEnabled: ptr.Bool(true), SSOEnabled: ptr.Bool(false)},
}, http.StatusOK, &updateInviteResp)
require.True(t, updateInviteResp.Invite.MFAEnabled)
}
func (s *integrationEnterpriseTestSuite) TestSSOJITProvisioning() {
t := s.T()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {
"server_url": "https://localhost:8080"
},
"sso_settings": {
"enable_sso": true,
"entity_id": "https://localhost:8080",
"idp_name": "SimpleSAML",
"metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
"enable_jit_provisioning": false
}
}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.False(t, acResp.SSOSettings.EnableJITProvisioning)
// users can't be created if SSO is disabled
body := s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
// ensure theresn't a user in the DB
_, err := s.ds.UserByEmail(context.Background(), "sso_user@example.com")
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
// If enable_jit_provisioning is enabled Roles won't be updated for existing SSO users.
// enable JIT provisioning
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"sso_settings": {
"enable_sso": true,
"entity_id": "https://localhost:8080",
"idp_name": "SimpleSAML",
"metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
"enable_jit_provisioning": true
}
}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.True(t, acResp.SSOSettings.EnableJITProvisioning)
// a new user is created and redirected accordingly
body = s.LoginSSOUser("sso_user", "user123#")
// a successful redirect has this content
require.Contains(t, body, "Redirecting to Fleet at ...")
user, err := s.ds.UserByEmail(context.Background(), "sso_user@example.com")
require.NoError(t, err)
require.Equal(t, "sso_user@example.com", user.Email)
// a new activity item is created
activitiesResp := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
require.NoError(t, activitiesResp.Err)
require.NotEmpty(t, activitiesResp.Activities)
require.Condition(t, func() bool {
for _, a := range activitiesResp.Activities {
if (a.Type == fleet.ActivityTypeUserAddedBySSO{}.ActivityName()) && *a.ActorEmail == "sso_user@example.com" {
return true
}
}
return false
})
// Test that roles are not updated for an existing user when SSO attributes are not set.
// Change role to global admin first.
user.GlobalRole = ptr.String("admin")
err = s.ds.SaveUser(context.Background(), user)
require.NoError(t, err)
// Login should NOT change the role to the default (global observer) because SSO attributes
// are not set for this user (see ../../tools/saml/users.php).
body = s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "Redirecting to Fleet at ...")
user, err = s.ds.UserByEmail(context.Background(), "sso_user@example.com")
require.NoError(t, err)
require.NotNil(t, user.GlobalRole)
require.Equal(t, *user.GlobalRole, "admin")
require.Equal(t, "SSO User 1", user.Name)
// A user with pre-configured roles can be created
// see `tools/saml/users.php` for details.
body = s.LoginSSOUser("sso_user_3_global_admin", "user123#")
require.Contains(t, body, "Redirecting to Fleet at ...")
user3, err := s.ds.UserByEmail(context.Background(), "sso_user_3_global_admin@example.com")
require.NoError(t, err)
require.Equal(t, "sso_user_3_global_admin@example.com", user3.Email)
require.Equal(t, "SSO User 3", user3.Name)
require.NotNil(t, user3.GlobalRole)
require.Equal(t, fleet.RoleAdmin, *user3.GlobalRole)
// Test that roles are updated for an existing user when SSO attributes are set.
// Change role to global maintainer first.
user3, err = s.ds.UserByEmail(context.Background(), "sso_user_3_global_admin@example.com")
require.NoError(t, err)
require.Equal(t, "sso_user_3_global_admin@example.com", user3.Email)
user3.GlobalRole = ptr.String("maintainer")
err = s.ds.SaveUser(context.Background(), user3)
require.NoError(t, err)
// Login should change the role to the configured role in the SSO attributes (global admin).
body = s.LoginSSOUser("sso_user_3_global_admin", "user123#")
require.Contains(t, body, "Redirecting to Fleet at ...")
user3, err = s.ds.UserByEmail(context.Background(), "sso_user_3_global_admin@example.com")
require.NoError(t, err)
require.Equal(t, "sso_user_3_global_admin@example.com", user3.Email)
require.Equal(t, "SSO User 3", user3.Name)
require.NotNil(t, user3.GlobalRole)
require.Equal(t, fleet.RoleAdmin, *user3.GlobalRole)
// We cannot use NewTeam and must use adhoc SQL because the teams.id is
// auto-incremented and other tests cause it to be different than what we need (ID=1).
var execErr error
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, execErr = db.ExecContext(context.Background(), `INSERT INTO teams (id, name) VALUES (1, 'Foobar') ON DUPLICATE KEY UPDATE name = VALUES(name);`)
return execErr
})
require.NoError(t, execErr)
// Create a team for the test below.
_, err = s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team_" + t.Name(),
Description: "desc team_" + t.Name(),
})
require.NoError(t, err)
// A user with pre-configured roles can be created,
// see `tools/saml/users.php` for details.
body = s.LoginSSOUser("sso_user_4_team_maintainer", "user123#")
require.Contains(t, body, "Redirecting to Fleet at ...")
user4, err := s.ds.UserByEmail(context.Background(), "sso_user_4_team_maintainer@example.com")
require.NoError(t, err)
require.Equal(t, "sso_user_4_team_maintainer@example.com", user4.Email)
require.Equal(t, "SSO User 4", user4.Name)
require.Nil(t, user4.GlobalRole)
require.Len(t, user4.Teams, 1)
require.Equal(t, uint(1), user4.Teams[0].ID)
require.Equal(t, fleet.RoleMaintainer, user4.Teams[0].Role)
// A user with pre-configured roles can be created,
// see `tools/saml/users.php` for details.
// This user has the following configuration (the last two are ignored by Fleet):
// - 'FLEET_JIT_USER_ROLE_TEAM_1' => 'admin',
// - 'FLEET_JIT_USER_ROLE_GLOBAL' => 'null',
// - 'FLEET_JIT_USER_ROLE_TEAM_2' => 'null',
body = s.LoginSSOUser("sso_user_5_team_admin", "user123#")
require.Contains(t, body, "Redirecting to Fleet at ...")
user5, err := s.ds.UserByEmail(context.Background(), "sso_user_5_team_admin@example.com")
require.NoError(t, err)
assert.Equal(t, "sso_user_5_team_admin@example.com", user5.Email)
assert.Equal(t, "SSO User 5", user5.Name)
require.Nil(t, user5.GlobalRole)
require.Len(t, user5.Teams, 1)
require.Equal(t, uint(1), user5.Teams[0].ID)
require.Equal(t, fleet.RoleAdmin, user5.Teams[0].Role)
// A user with pre-configured roles can be created,
// see `tools/saml/users.php` for details.
// This user has the following configuration (all ignored by Fleet thus added as global observer):
// - 'FLEET_JIT_USER_ROLE_GLOBAL' => 'null',
// - 'FLEET_JIT_USER_ROLE_TEAM_1' => 'null',
body = s.LoginSSOUser("sso_user_6_global_observer", "user123#")
require.Contains(t, body, "Redirecting to Fleet at ...")
user6, err := s.ds.UserByEmail(context.Background(), "sso_user_6_global_observer@example.com")
require.NoError(t, err)
assert.Equal(t, "sso_user_6_global_observer@example.com", user6.Email)
assert.Equal(t, "SSO User 6", user6.Name)
require.NotNil(t, user6.GlobalRole)
require.Equal(t, fleet.RoleObserver, *user6.GlobalRole)
}
func (s *integrationEnterpriseTestSuite) TestDistributedReadWithFeatures() {
t := s.T()
// Global config has both features enabled
spec := []byte(`
features:
additional_queries: null
enable_host_users: true
enable_software_inventory: true
`)
s.applyConfig(spec)
// Team config has only additional queries enabled
a := json.RawMessage(`{"time": "SELECT * FROM time"}`)
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 8324,
Name: "team1_" + t.Name(),
Description: "desc team1_" + t.Name(),
Config: fleet.TeamConfig{
Features: fleet.Features{
EnableHostUsers: false,
EnableSoftwareInventory: false,
AdditionalQueries: &a,
},
},
})
require.NoError(t, err)
// Create a host without a team
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
s.lq.On("QueriesForHost", host.ID).Return(map[string]string{fmt.Sprintf("%d", host.ID): "select 1 from osquery;"}, nil)
// ensure we can read distributed queries for the host
err = s.ds.UpdateHostRefetchRequested(context.Background(), host.ID, true)
require.NoError(t, err)
// get distributed queries for the host
req := getDistributedQueriesRequest{NodeKey: *host.NodeKey}
var dqResp getDistributedQueriesResponse
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.Contains(t, dqResp.Queries, "fleet_detail_query_users")
require.Contains(t, dqResp.Queries, "fleet_detail_query_software_macos")
require.NotContains(t, dqResp.Queries, "fleet_additional_query_time")
// add the host to team1
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
require.NoError(t, err)
err = s.ds.UpdateHostRefetchRequested(context.Background(), host.ID, true)
require.NoError(t, err)
req = getDistributedQueriesRequest{NodeKey: *host.NodeKey}
dqResp = getDistributedQueriesResponse{}
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.NotContains(t, dqResp.Queries, "fleet_detail_query_users")
require.NotContains(t, dqResp.Queries, "fleet_detail_query_software_macos")
require.Contains(t, dqResp.Queries, "fleet_additional_query_time")
}
func (s *integrationEnterpriseTestSuite) TestListHosts() {
t := s.T()
// create a couple of hosts
host1, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
host2, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + "2"),
NodeKey: ptr.String(t.Name() + "2"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sbar.local", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
host3, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + "3"),
NodeKey: ptr.String(t.Name() + "3"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sbaz.local", t.Name()),
Platform: "windows",
})
require.NoError(t, err)
require.NotNil(t, host3)
// set disk space information for some hosts (none provided for host3)
require.NoError(t, s.ds.SetOrUpdateHostDisksSpace(context.Background(), host1.ID, 10.0, 2.0, 500.0))
require.NoError(t, s.ds.SetOrUpdateHostDisksSpace(context.Background(), host2.ID, 32.0, 4.0, 1000.0))
var resp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp)
require.Len(t, resp.Hosts, 3)
allHostsLabel, err := s.ds.GetLabelSpec(context.Background(), "All hosts")
require.NoError(t, err)
for _, h := range resp.Hosts {
err = s.ds.RecordLabelQueryExecutions(
context.Background(), h.Host, map[uint]*bool{allHostsLabel.ID: ptr.Bool(true)}, time.Now(), false,
)
require.NoError(t, err)
}
resp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", allHostsLabel.ID), nil, http.StatusOK, &resp, "low_disk_space", "32")
require.Len(t, resp.Hosts, 1)
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "low_disk_space", "32")
require.Len(t, resp.Hosts, 1)
assert.Equal(t, host1.ID, resp.Hosts[0].ID)
assert.Equal(t, 10.0, resp.Hosts[0].GigsDiskSpaceAvailable)
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "low_disk_space", "100")
require.Len(t, resp.Hosts, 2)
// returns an error when the low_disk_space value is invalid (outside 1-100)
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusBadRequest, &resp, "low_disk_space", "101")
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusBadRequest, &resp, "low_disk_space", "0")
// counting hosts works with and without the filter too
var countResp countHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp)
require.Equal(t, 3, countResp.Count)
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "low_disk_space", "32")
require.Equal(t, 1, countResp.Count)
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "low_disk_space", "100")
require.Equal(t, 2, countResp.Count)
// host summary returns counts for low disk space
var summaryResp getHostSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &summaryResp, "low_disk_space", "32")
require.Equal(t, uint(3), summaryResp.TotalsHostsCount)
require.NotNil(t, summaryResp.LowDiskSpaceCount)
require.Equal(t, uint(1), *summaryResp.LowDiskSpaceCount)
summaryResp = getHostSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &summaryResp, "platform", "windows", "low_disk_space", "32")
require.Equal(t, uint(1), summaryResp.TotalsHostsCount)
require.NotNil(t, summaryResp.LowDiskSpaceCount)
require.Equal(t, uint(0), *summaryResp.LowDiskSpaceCount)
// all possible filters
summaryResp = getHostSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &summaryResp, "team_id", "1", "platform", "linux", "low_disk_space", "32")
require.Equal(t, uint(0), summaryResp.TotalsHostsCount)
require.NotNil(t, summaryResp.LowDiskSpaceCount)
require.Equal(t, uint(0), *summaryResp.LowDiskSpaceCount)
// without low_disk_space, does not return the count
summaryResp = getHostSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &summaryResp, "team_id", "1", "platform", "linux")
require.Equal(t, uint(0), summaryResp.TotalsHostsCount)
require.Nil(t, summaryResp.LowDiskSpaceCount)
// Add a failing policy
ctx := context.Background()
qr, err := s.ds.NewQuery(
ctx, &fleet.Query{
Name: "TestQueryEnterpriseTestListHosts",
Description: "Some description",
Query: "select * from osquery;",
ObserverCanRun: true,
Logging: fleet.LoggingSnapshot,
},
)
require.NoError(t, err)
// add a global policy
gpParams := globalPolicyRequest{
QueryID: &qr.ID,
Resolution: "some global resolution",
}
gpResp := globalPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/policies", gpParams, http.StatusOK, &gpResp)
require.NotNil(t, gpResp.Policy)
// add a failing policy execution
require.NoError(
t, s.ds.RecordPolicyQueryExecutions(
ctx, host1,
map[uint]*bool{gpResp.Policy.ID: ptr.Bool(false)}, time.Now(), false,
),
)
// populate software for hosts
now := time.Now()
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err = s.ds.UpdateHostSoftware(context.Background(), host1.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(context.Background(), host1, false))
inserted, err := s.ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{
SoftwareID: host1.Software[0].ID,
CVE: "cve-123-123-123",
}, fleet.NVDSource)
require.NoError(t, err)
require.True(t, inserted)
vulnMeta := []fleet.CVEMeta{{
CVE: "cve-123-123-123",
CVSSScore: ptr.Float64(9.8),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
Published: &now,
Description: "a long description of the cve",
}}
require.NoError(t, s.ds.InsertCVEMeta(context.Background(), vulnMeta))
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium})
require.NoError(t, s.ds.UpdateHostIssuesVulnerabilities(ctx))
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "true")
require.Len(t, resp.Hosts, 3)
for _, h := range resp.Hosts {
if h.ID == host1.ID {
require.NotEmpty(t, h.Software)
require.Len(t, h.Software, 1)
require.NotEmpty(t, h.Software[0].Vulnerabilities)
s := &vulnMeta[0].Description
require.Equal(t, &vulnMeta[0].CVSSScore, h.Software[0].Vulnerabilities[0].CVSSScore)
require.Equal(t, &vulnMeta[0].EPSSProbability, h.Software[0].Vulnerabilities[0].EPSSProbability)
require.Equal(t, &vulnMeta[0].CISAKnownExploit, h.Software[0].Vulnerabilities[0].CISAKnownExploit)
require.Equal(t, &s, h.Software[0].Vulnerabilities[0].Description)
assert.Equal(t, uint64(1), h.HostIssues.FailingPoliciesCount)
assert.Equal(t, uint64(1), *h.HostIssues.CriticalVulnerabilitiesCount)
assert.Equal(t, uint64(2), h.HostIssues.TotalIssuesCount)
} else {
assert.Zero(t, h.HostIssues.FailingPoliciesCount)
assert.Zero(t, *h.HostIssues.CriticalVulnerabilitiesCount)
assert.Zero(t, h.HostIssues.TotalIssuesCount)
}
}
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "without_vulnerability_details")
require.Len(t, resp.Hosts, 3)
for _, h := range resp.Hosts {
if h.ID == host1.ID {
require.NotEmpty(t, h.Software)
require.Len(t, h.Software, 1)
require.NotEmpty(t, h.Software[0].Vulnerabilities)
require.Nil(t, h.Software[0].Vulnerabilities[0].CVSSScore)
require.Nil(t, h.Software[0].Vulnerabilities[0].EPSSProbability)
require.Nil(t, h.Software[0].Vulnerabilities[0].CISAKnownExploit)
require.Nil(t, h.Software[0].Vulnerabilities[0].Description)
assert.Equal(t, uint64(1), h.HostIssues.FailingPoliciesCount)
assert.Equal(t, uint64(1), *h.HostIssues.CriticalVulnerabilitiesCount)
assert.Equal(t, uint64(2), h.HostIssues.TotalIssuesCount)
} else {
assert.Zero(t, h.HostIssues.FailingPoliciesCount)
assert.Zero(t, *h.HostIssues.CriticalVulnerabilitiesCount)
assert.Zero(t, h.HostIssues.TotalIssuesCount)
}
}
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "false")
require.Len(t, resp.Hosts, 3)
for _, h := range resp.Hosts {
require.Empty(t, h.Software)
}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", allHostsLabel.ID), nil, http.StatusOK, &resp)
assert.Len(t, resp.Hosts, 3)
for _, h := range resp.Hosts {
if h.ID == host1.ID {
assert.Equal(t, uint64(1), h.HostIssues.FailingPoliciesCount)
assert.Equal(t, uint64(1), *h.HostIssues.CriticalVulnerabilitiesCount)
assert.Equal(t, uint64(2), h.HostIssues.TotalIssuesCount)
} else {
assert.Zero(t, h.HostIssues.FailingPoliciesCount)
assert.Zero(t, *h.HostIssues.CriticalVulnerabilitiesCount)
assert.Zero(t, h.HostIssues.TotalIssuesCount)
}
}
// Test ordering by issues
s.DoJSON(
"GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "order_key", "issues",
) // defaults to ascending order (lowest issues to most issues)
require.Len(t, resp.Hosts, 3)
assert.Equal(t, host1.ID, resp.Hosts[2].ID)
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", allHostsLabel.ID), nil, http.StatusOK, &resp, "order_key", "issues",
"order_direction", "desc",
)
require.Len(t, resp.Hosts, 3)
assert.Equal(t, host1.ID, resp.Hosts[0].ID)
}
func (s *integrationEnterpriseTestSuite) TestHostHealth() {
t := s.T()
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team1",
})
require.NoError(t, err)
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
OsqueryHostID: ptr.String(t.Name() + "hostid1"),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(t.Name() + "nodekey1"),
UUID: t.Name() + "uuid1",
Hostname: t.Name() + "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
OSVersion: "Mac OS X 10.14.6",
Platform: "darwin",
CPUType: "cpuType",
TeamID: ptr.Uint(team.ID),
})
require.NoError(t, err)
require.NotNil(t, host)
passingTeamPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{
Name: "Passing Global Policy",
Query: "select 1",
Resolution: "Run this command to fix it",
})
require.NoError(t, err)
failingTeamPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{
Name: "Failing Global Policy",
Query: "select 1",
Resolution: "Run this command to fix it",
Critical: true,
})
require.NoError(t, err)
passingGlobalPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{
Name: "Passing Global Policy",
Query: "select 1",
Resolution: "Run this command to fix it",
})
require.NoError(t, err)
failingGlobalPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{
Name: "Failing Global Policy",
Query: "select 1",
Resolution: "Run this command to fix it",
Critical: false,
})
require.NoError(t, err)
require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingGlobalPolicy.ID: ptr.Bool(false)}, time.Now(), false))
require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingGlobalPolicy.ID: ptr.Bool(true)}, time.Now(), false))
require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingTeamPolicy.ID: ptr.Bool(false)}, time.Now(), false))
require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingTeamPolicy.ID: ptr.Bool(true)}, time.Now(), false))
hh := getHostHealthResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/health", host.ID), nil, http.StatusOK, &hh)
require.Equal(t, host.ID, hh.HostID)
assert.NotNil(t, hh.HostHealth)
assert.Equal(t, host.OSVersion, hh.HostHealth.OsVersion)
assert.Equal(t, 2, hh.HostHealth.FailingPoliciesCount)
assert.Equal(t, ptr.Int(1), hh.HostHealth.FailingCriticalPoliciesCount)
assert.Contains(t, hh.HostHealth.FailingPolicies, &fleet.HostHealthFailingPolicy{
ID: failingTeamPolicy.ID,
Name: failingTeamPolicy.Name,
Resolution: failingTeamPolicy.Resolution,
Critical: ptr.Bool(true),
})
assert.Contains(t, hh.HostHealth.FailingPolicies, &fleet.HostHealthFailingPolicy{
ID: failingGlobalPolicy.ID,
Name: failingGlobalPolicy.Name,
Resolution: failingGlobalPolicy.Resolution,
Critical: ptr.Bool(false),
})
}
func (s *integrationEnterpriseTestSuite) TestListVulnerabilities() {
t := s.T()
var resp listVulnerabilitiesResponse
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp)
// Invalid Order Key
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusBadRequest, &resp, "order_key", "foo", "order_direction", "asc")
// EE Only Order Key
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "order_key", "cvss_score", "order_direction", "asc")
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp)
require.Len(s.T(), resp.Vulnerabilities, 0)
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "2"),
OsqueryHostID: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "2"),
UUID: t.Name() + "2",
Hostname: t.Name() + "foo2.local",
PrimaryIP: "192.168.1.2",
PrimaryMac: "30-65-EC-6F-C4-59",
Platform: "windows",
})
require.NoError(t, err)
err = s.ds.UpdateHostOperatingSystem(context.Background(), host.ID, fleet.OperatingSystem{
Name: "Windows 11 Enterprise 22H2",
Version: "10.0.19042.1234",
Platform: "windows",
})
require.NoError(t, err)
allos, err := s.ds.ListOperatingSystems(context.Background())
require.NoError(t, err)
var os fleet.OperatingSystem
for _, o := range allos {
if o.ID > os.ID {
os = o
}
}
err = s.ds.UpdateOSVersions(context.Background())
require.NoError(t, err)
_, err = s.ds.InsertOSVulnerability(context.Background(), fleet.OSVulnerability{
OSID: os.ID,
CVE: "CVE-2021-1234",
ResolvedInVersion: ptr.String("10.0.19043.2013"),
}, fleet.MSRCSource)
require.NoError(t, err)
res, err := s.ds.UpdateHostSoftware(context.Background(), host.ID, []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
})
require.NoError(t, err)
sw := res.Inserted[0]
_, err = s.ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{
SoftwareID: sw.ID,
CVE: "CVE-2021-1235",
}, fleet.NVDSource)
require.NoError(t, err)
// insert CVEMeta
mockTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
err = s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{
{
CVE: "CVE-2021-1234",
CVSSScore: ptr.Float64(7.5),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
Published: ptr.Time(mockTime),
Description: "Test CVE 2021-1234",
},
{
CVE: "CVE-2021-1235",
CVSSScore: ptr.Float64(5.4),
EPSSProbability: ptr.Float64(0.6),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(mockTime),
Description: "Test CVE 2021-1235",
},
})
require.NoError(t, err)
err = s.ds.UpdateVulnerabilityHostCounts(context.Background(), 5)
require.NoError(t, err)
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp)
require.Len(s.T(), resp.Vulnerabilities, 2)
require.Equal(t, resp.Count, uint(2))
require.False(t, resp.Meta.HasPreviousResults)
require.False(t, resp.Meta.HasNextResults)
require.Empty(t, resp.Err)
expected := map[string]struct {
fleet.CVE
HostCount uint
DetailsLink string
Source fleet.VulnerabilitySource
}{
"CVE-2021-1234": {
HostCount: 1,
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2021-1234",
CVE: fleet.CVE{
CVE: "CVE-2021-1234",
CVSSScore: ptr.Float64Ptr(7.5),
EPSSProbability: ptr.Float64Ptr(0.5),
CISAKnownExploit: ptr.BoolPtr(true),
CVEPublished: ptr.TimePtr(mockTime),
Description: ptr.StringPtr("Test CVE 2021-1234"),
},
},
"CVE-2021-1235": {
HostCount: 1,
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2021-1235",
CVE: fleet.CVE{
CVE: "CVE-2021-1235",
CVSSScore: ptr.Float64Ptr(5.4),
EPSSProbability: ptr.Float64Ptr(0.6),
CISAKnownExploit: ptr.BoolPtr(false),
CVEPublished: ptr.TimePtr(mockTime),
Description: ptr.StringPtr("Test CVE 2021-1235"),
},
},
}
for _, vuln := range resp.Vulnerabilities {
expectedVuln, ok := expected[vuln.CVE.CVE]
require.True(t, ok)
require.Equal(t, expectedVuln.HostCount, vuln.HostsCount)
require.Equal(t, expectedVuln.DetailsLink, vuln.DetailsLink)
require.Equal(t, expectedVuln.CVE.CVE, vuln.CVE.CVE)
}
// EE Exploit Filter
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "exploit", "true")
require.Len(t, resp.Vulnerabilities, 1)
require.Equal(t, "CVE-2021-1234", resp.Vulnerabilities[0].CVE.CVE)
// Test Team Filter
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "team_id", "1")
require.Len(s.T(), resp.Vulnerabilities, 0)
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
require.NoError(t, err)
err = s.ds.UpdateVulnerabilityHostCounts(context.Background(), 5)
require.NoError(t, err)
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "team_id", fmt.Sprintf("%d", team.ID))
require.Len(t, resp.Vulnerabilities, 2)
require.Equal(t, uint(2), resp.Count)
require.False(t, resp.Meta.HasPreviousResults)
require.False(t, resp.Meta.HasNextResults)
require.Empty(t, resp.Err)
for _, vuln := range resp.Vulnerabilities {
expectedVuln, ok := expected[vuln.CVE.CVE]
require.True(t, ok)
require.Equal(t, expectedVuln.HostCount, vuln.HostsCount)
require.Equal(t, expectedVuln.DetailsLink, vuln.DetailsLink)
require.Equal(t, expectedVuln.CVE.CVE, vuln.CVE.CVE)
}
var gResp getVulnerabilityResponse
s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusOK, &gResp)
require.Empty(t, gResp.Err)
require.Equal(t, "CVE-2021-1234", gResp.Vulnerability.CVE.CVE)
require.Equal(t, uint(1), gResp.Vulnerability.HostsCount)
require.Equal(t, "https://nvd.nist.gov/vuln/detail/CVE-2021-1234", gResp.Vulnerability.DetailsLink)
require.Equal(t, ptr.StringPtr("Test CVE 2021-1234"), gResp.Vulnerability.Description)
require.Equal(t, ptr.Float64Ptr(7.5), gResp.Vulnerability.CVSSScore)
require.Equal(t, ptr.BoolPtr(true), gResp.Vulnerability.CISAKnownExploit)
require.Equal(t, ptr.Float64Ptr(0.5), gResp.Vulnerability.EPSSProbability)
require.Equal(t, ptr.TimePtr(mockTime), gResp.Vulnerability.CVEPublished)
require.Len(t, gResp.OSVersions, 1)
require.Equal(t, "Windows 11 Enterprise 22H2 10.0.19042.1234", gResp.OSVersions[0].Name)
require.Equal(t, "Windows 11 Enterprise 22H2", gResp.OSVersions[0].NameOnly)
require.Equal(t, "windows", gResp.OSVersions[0].Platform)
require.Equal(t, "10.0.19042.1234", gResp.OSVersions[0].Version)
require.Equal(t, 1, gResp.OSVersions[0].HostsCount)
require.Equal(t, "10.0.19043.2013", *gResp.OSVersions[0].ResolvedInVersion)
}
func (s *integrationEnterpriseTestSuite) TestOSVersions() {
t := s.T()
testOS := fleet.OperatingSystem{Name: "Windows 11 Pro", Version: "10.0.22621.2861", Arch: "x86_64", KernelVersion: "10.0.22621.2861", Platform: "windows"}
hosts := s.createHosts(t)
var resp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp)
require.Len(t, resp.Hosts, len(hosts))
// set operating system information on a host
require.NoError(t, s.ds.UpdateHostOperatingSystem(context.Background(), hosts[0].ID, testOS))
var osinfo struct {
ID uint `db:"id"`
OSVersionID uint `db:"os_version_id"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &osinfo,
`SELECT id, os_version_id FROM operating_systems WHERE name = ? AND version = ? AND arch = ? AND kernel_version = ? AND platform = ?`,
testOS.Name, testOS.Version, testOS.Arch, testOS.KernelVersion, testOS.Platform)
})
require.Greater(t, osinfo.ID, uint(0))
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "os_name", testOS.Name, "os_version", testOS.Version)
require.Len(t, resp.Hosts, 1)
expected := resp.Hosts[0]
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "os_id", fmt.Sprintf("%d", osinfo.ID))
require.Len(t, resp.Hosts, 1)
require.Equal(t, expected, resp.Hosts[0])
// generate aggregated stats
require.NoError(t, s.ds.UpdateOSVersions(context.Background()))
// insert OS Vulns
_, err := s.ds.InsertOSVulnerability(context.Background(), fleet.OSVulnerability{
OSID: osinfo.ID,
CVE: "CVE-2021-1234",
}, fleet.MSRCSource)
require.NoError(t, err)
// insert CVE MEta
vulnMeta := []fleet.CVEMeta{
{
CVE: "CVE-2021-1234",
CVSSScore: ptr.Float64(5.4),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
Published: ptr.Time(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
Description: "a long description of the cve",
},
}
require.NoError(t, s.ds.InsertCVEMeta(context.Background(), vulnMeta))
var osVersionsResp osVersionsResponse
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusOK, &osVersionsResp)
require.Len(t, osVersionsResp.OSVersions, 1)
require.Equal(t, 1, osVersionsResp.OSVersions[0].HostsCount)
require.Equal(t, fmt.Sprintf("%s %s", testOS.Name, testOS.Version), osVersionsResp.OSVersions[0].Name)
require.Equal(t, testOS.Name, osVersionsResp.OSVersions[0].NameOnly)
require.Equal(t, testOS.Version, osVersionsResp.OSVersions[0].Version)
require.Equal(t, testOS.Platform, osVersionsResp.OSVersions[0].Platform)
require.Len(t, osVersionsResp.OSVersions[0].Vulnerabilities, 1)
require.Equal(t, "CVE-2021-1234", osVersionsResp.OSVersions[0].Vulnerabilities[0].CVE)
require.Equal(t, "https://nvd.nist.gov/vuln/detail/CVE-2021-1234", osVersionsResp.OSVersions[0].Vulnerabilities[0].DetailsLink)
require.Equal(t, *vulnMeta[0].CVSSScore, **osVersionsResp.OSVersions[0].Vulnerabilities[0].CVSSScore)
require.Equal(t, *vulnMeta[0].EPSSProbability, **osVersionsResp.OSVersions[0].Vulnerabilities[0].EPSSProbability)
require.Equal(t, *vulnMeta[0].CISAKnownExploit, **osVersionsResp.OSVersions[0].Vulnerabilities[0].CISAKnownExploit)
require.Equal(t, *vulnMeta[0].Published, **osVersionsResp.OSVersions[0].Vulnerabilities[0].CVEPublished)
require.Equal(t, vulnMeta[0].Description, **osVersionsResp.OSVersions[0].Vulnerabilities[0].Description)
expectedOSVersion := osVersionsResp.OSVersions[0]
var osVersionResp getOSVersionResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusOK, &osVersionResp)
require.Equal(t, &expectedOSVersion, osVersionResp.OSVersion)
// OS versions with invalid team
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusNotFound, &osVersionResp, "team_id",
"99999",
)
// Create team and ask for the OS versions from the team (with no hosts) -- should get 404.
tr := teamResponse{}
s.DoJSON(
"POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String("os_versions_team"),
},
}, http.StatusOK, &tr,
)
osVersionResp = getOSVersionResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusOK, &osVersionResp, "team_id",
fmt.Sprintf("%d", tr.Team.ID),
)
assert.Zero(t, osVersionResp.OSVersion.HostsCount)
// return empty json if UpdateOSVersions cron hasn't run yet for new team
team0, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "new team"})
require.NoError(t, err)
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team0.ID, []uint{hosts[0].ID})))
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusOK, &osVersionsResp, "team_id", fmt.Sprintf("%d", team0.ID))
require.Len(t, osVersionsResp.OSVersions, 0)
// return err if team_id is invalid
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusBadRequest, &osVersionsResp, "team_id", "invalid")
// Create another team and a team user
team1, err := s.ds.NewTeam(
context.Background(), &fleet.Team{
ID: 42,
Name: "team1-os_version",
Description: "desc team1",
},
)
require.NoError(t, err)
// Create a new admin for team1.
password := test.GoodPassword
email := "admin-team1-os_version@example.com"
u := &fleet.User{
Name: "admin team1",
Email: email,
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team1,
Role: fleet.RoleAdmin,
},
},
}
require.NoError(t, u.SetPassword(password, 10, 10))
_, err = s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.setTokenForTest(t, email, test.GoodPassword)
// generate aggregated stats
require.NoError(t, s.ds.UpdateOSVersions(context.Background()))
// team1 user does not have access to team0 host
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusOK, &osVersionsResp)
assert.Empty(t, osVersionsResp.OSVersions)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusOK, &osVersionResp)
assert.Zero(t, osVersionResp.OSVersion.HostsCount)
// Move host from team0 to team1
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team1.ID, []uint{hosts[0].ID})))
require.NoError(t, s.ds.UpdateOSVersions(context.Background()))
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusOK, &osVersionsResp)
require.Len(t, osVersionsResp.OSVersions, 1)
assert.Equal(t, expectedOSVersion, osVersionsResp.OSVersions[0])
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusOK, &osVersionResp)
require.Equal(t, &expectedOSVersion, osVersionResp.OSVersion)
// Team user is forbidden to access invalid team
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusForbidden, &osVersionResp, "team_id",
"99999",
)
// team user doesn't have acess to "no team"
osVersionsResp = osVersionsResponse{}
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusForbidden, &osVersionsResp, "team_id", "0")
require.Len(t, osVersionsResp.OSVersions, 0)
// team_id=0 is supported and returns results for hosts in "no team"
s.token = getTestAdminToken(t, s.server)
// no hosts, the results are empty
osVersionsResp = osVersionsResponse{}
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusOK, &osVersionsResp, "team_id", "0")
require.Len(t, osVersionsResp.OSVersions, 0)
osVersionsResp = osVersionsResponse{}
// move the host to "no team" and update the stats
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(nil, []uint{hosts[0].ID})))
require.NoError(t, s.ds.UpdateOSVersions(context.Background()))
s.DoJSON("GET", "/api/latest/fleet/os_versions", nil, http.StatusOK, &osVersionsResp, "team_id", "0")
require.Len(t, osVersionsResp.OSVersions, 1)
osVersionResp = getOSVersionResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/os_versions/%d", osinfo.OSVersionID), nil, http.StatusOK, &osVersionResp, "team_id", "0")
require.Equal(t, &expectedOSVersion, osVersionResp.OSVersion)
require.Equal(t, 1, osVersionResp.OSVersion.HostsCount)
}
func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() {
t := s.T()
// create a host with device token to test device authenticated routes
tkn := "D3V1C370K3N"
h := createHostAndDeviceToken(t, s.ds, tkn)
orbitKey := setOrbitEnrollment(t, h, s.ds)
h.OrbitNodeKey = &orbitKey
windowsOnly := windowsMDMConfigurationRequiredEndpoints()
for _, route := range mdmConfigurationRequiredEndpoints() {
var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured
path := route.path
if slices.Contains(windowsOnly, path) {
expectedErr = fleet.ErrWindowsMDMNotConfigured
}
if route.deviceAuthenticated {
path = fmt.Sprintf(path, tkn)
}
var params any
if route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status" {
params = getOrbitSetupExperienceStatusRequest{
OrbitNodeKey: *h.OrbitNodeKey,
}
}
res := s.Do(route.method, path, params, expectedErr.StatusCode())
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, expectedErr.Error(), fmt.Sprintf("%s %s", route.method, path))
}
fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL)
t.Cleanup(fleetdmSrv.Close)
// Always accessible
var reqCSRResp requestMDMAppleCSRResponse
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusOK, &reqCSRResp)
s.Do("POST", "/api/latest/fleet/mdm/apple/dep/key_pair", nil, http.StatusOK)
// setting enable release device manually requires MDM
res := s.Do("PATCH", "/api/v1/fleet/setup_experience", fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error())
res = s.Do("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
"mdm": { "macos_setup": { "enable_release_device_manually": true } }
}`), http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Couldn't update macos_setup because MDM features aren't turned on in Fleet.`)
}
func (s *integrationEnterpriseTestSuite) TestGlobalPolicyCreateReadPatch() {
t := s.T()
fields := []string{"Query", "Name", "Description", "Resolution", "Platform", "Critical"}
createPol1 := &globalPolicyResponse{}
createPol1Req := &globalPolicyRequest{
Query: "query",
Name: "name1",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: true,
}
s.DoJSON("POST", "/api/latest/fleet/policies", createPol1Req, http.StatusOK, &createPol1)
allEqual(t, createPol1Req, createPol1.Policy, fields...)
createPol2 := &globalPolicyResponse{}
createPol2Req := &globalPolicyRequest{
Query: "query",
Name: "name2",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: false,
}
s.DoJSON("POST", "/api/latest/fleet/policies", createPol2Req, http.StatusOK, &createPol2)
allEqual(t, createPol2Req, createPol2.Policy, fields...)
listPol := &listGlobalPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/policies", nil, http.StatusOK, listPol)
require.Len(t, listPol.Policies, 2)
sort.Slice(listPol.Policies, func(i, j int) bool {
return listPol.Policies[i].Name < listPol.Policies[j].Name
})
require.Equal(t, createPol1.Policy, listPol.Policies[0])
require.Equal(t, createPol2.Policy, listPol.Policies[1])
// match policy by name with leading/trailing whitespace
listPolByName := &listGlobalPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/policies", nil, http.StatusOK, listPolByName, "query", " name1 ")
require.Len(t, listPolByName.Policies, 1)
require.Equal(t, listPolByName.Policies[0].Name, "name1")
patchPol1Req := &modifyGlobalPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Name: ptr.String("newName1"),
Query: ptr.String("newQuery"),
Description: ptr.String("newDescription"),
Resolution: ptr.String("newResolution"),
Platform: ptr.String("windows"),
Critical: ptr.Bool(false),
},
}
patchPol1 := &modifyGlobalPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/policies/%d", createPol1.Policy.ID), patchPol1Req, http.StatusOK, patchPol1)
allEqual(t, patchPol1Req, patchPol1.Policy, fields...)
patchPol2Req := &modifyGlobalPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Name: ptr.String("newName2"),
Query: ptr.String("newQuery"),
Description: ptr.String("newDescription"),
Resolution: ptr.String("newResolution"),
Platform: ptr.String("windows"),
Critical: ptr.Bool(true),
},
}
patchPol2 := &modifyGlobalPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/policies/%d", createPol2.Policy.ID), patchPol2Req, http.StatusOK, patchPol2)
allEqual(t, patchPol2Req, patchPol2.Policy, fields...)
listPol = &listGlobalPoliciesResponse{}
s.DoJSON("GET", "/api/latest/fleet/policies", nil, http.StatusOK, listPol)
require.Len(t, listPol.Policies, 2)
sort.Slice(listPol.Policies, func(i, j int) bool {
return listPol.Policies[i].Name < listPol.Policies[j].Name
})
// not using require.Equal because "PATCH policies" returns the wrong updated timestamp.
allEqual(t, patchPol1.Policy, listPol.Policies[0], fields...)
allEqual(t, patchPol2.Policy, listPol.Policies[1], fields...)
getPol2 := &getPolicyByIDResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/policies/%d", createPol2.Policy.ID), nil, http.StatusOK, getPol2)
require.Equal(t, listPol.Policies[1], getPol2.Policy)
}
func (s *integrationEnterpriseTestSuite) TestTeamPolicyCreateReadPatch() {
fields := []string{"Query", "Name", "Description", "Resolution", "Platform", "Critical", "CalendarEventsEnabled"}
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 42,
Name: "team1",
Description: "desc team1",
})
require.NoError(s.T(), err)
createPol1 := &teamPolicyResponse{}
createPol1Req := &teamPolicyRequest{
Query: "query",
Name: "name1",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: true,
CalendarEventsEnabled: true,
}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol1Req, http.StatusOK, &createPol1)
allEqual(s.T(), createPol1Req, createPol1.Policy, fields...)
createPol2 := &teamPolicyResponse{}
createPol2Req := &teamPolicyRequest{
Query: "query",
Name: "name2",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: false,
CalendarEventsEnabled: false,
}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol2Req, http.StatusOK, &createPol2)
allEqual(s.T(), createPol2Req, createPol2.Policy, fields...)
listPol := &listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, listPol)
require.Len(s.T(), listPol.Policies, 2)
sort.Slice(listPol.Policies, func(i, j int) bool {
return listPol.Policies[i].Name < listPol.Policies[j].Name
})
require.Equal(s.T(), createPol1.Policy, listPol.Policies[0])
require.Equal(s.T(), createPol2.Policy, listPol.Policies[1])
patchPol1Req := &modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Name: ptr.String("newName1"),
Query: ptr.String("newQuery"),
Description: ptr.String("newDescription"),
Resolution: ptr.String("newResolution"),
Platform: ptr.String("windows"),
Critical: ptr.Bool(false),
CalendarEventsEnabled: ptr.Bool(false),
},
}
patchPol1 := &modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, createPol1.Policy.ID), patchPol1Req, http.StatusOK, patchPol1)
allEqual(s.T(), patchPol1Req, patchPol1.Policy, fields...)
patchPol2Req := &modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Name: ptr.String("newName2"),
Query: ptr.String("newQuery"),
Description: ptr.String("newDescription"),
Resolution: ptr.String("newResolution"),
Platform: ptr.String("windows"),
Critical: ptr.Bool(true),
CalendarEventsEnabled: ptr.Bool(true),
},
}
patchPol2 := &modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, createPol2.Policy.ID), patchPol2Req, http.StatusOK, patchPol2)
allEqual(s.T(), patchPol2Req, patchPol2.Policy, fields...)
listPol = &listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, listPol)
require.Len(s.T(), listPol.Policies, 2)
sort.Slice(listPol.Policies, func(i, j int) bool {
return listPol.Policies[i].Name < listPol.Policies[j].Name
})
// not using require.Equal because "PATCH policies" returns the wrong updated timestamp.
allEqual(s.T(), patchPol1.Policy, listPol.Policies[0], fields...)
allEqual(s.T(), patchPol2.Policy, listPol.Policies[1], fields...)
getPol2 := &getPolicyByIDResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, createPol2.Policy.ID), nil, http.StatusOK, getPol2)
require.Equal(s.T(), listPol.Policies[1], getPol2.Policy)
}
func (s *integrationEnterpriseTestSuite) TestResetAutomation() {
ctx := context.Background()
team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 42,
Name: "team1",
Description: "desc team1",
})
require.NoError(s.T(), err)
createPol1 := &teamPolicyResponse{}
createPol1Req := &teamPolicyRequest{
Query: "query",
Name: "name1",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: true,
}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol1Req, http.StatusOK, &createPol1)
createPol2 := &teamPolicyResponse{}
createPol2Req := &teamPolicyRequest{
Query: "query",
Name: "name2",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: false,
}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol2Req, http.StatusOK, &createPol2)
createPol3 := &teamPolicyResponse{}
createPol3Req := &teamPolicyRequest{
Query: "query",
Name: "name3",
Description: "description",
Resolution: "resolution",
Platform: "linux",
Critical: false,
}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol3Req, http.StatusOK, &createPol3)
var tmResp teamResponse
// modify the team's config - enable the webhook
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team1.ID), fleet.TeamPayload{WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "http://127/",
PolicyIDs: []uint{createPol1.Policy.ID, createPol2.Policy.ID},
HostBatchSize: 12345,
},
}}, http.StatusOK, &tmResp)
h1, err := s.ds.NewHost(ctx, &fleet.Host{})
require.NoError(s.T(), err)
err = s.ds.RecordPolicyQueryExecutions(ctx, h1, map[uint]*bool{
createPol1.Policy.ID: ptr.Bool(false),
createPol2.Policy.ID: ptr.Bool(false),
createPol3.Policy.ID: ptr.Bool(false), // This policy is not activated for automation in config.
}, time.Now(), false)
require.NoError(s.T(), err)
pfs, err := s.ds.OutdatedAutomationBatch(ctx)
require.NoError(s.T(), err)
require.Empty(s.T(), pfs)
s.DoJSON("POST", "/api/latest/fleet/automations/reset", resetAutomationRequest{
TeamIDs: nil,
PolicyIDs: []uint{},
}, http.StatusOK, &tmResp)
pfs, err = s.ds.OutdatedAutomationBatch(ctx)
require.NoError(s.T(), err)
require.Empty(s.T(), pfs)
s.DoJSON("POST", "/api/latest/fleet/automations/reset", resetAutomationRequest{
TeamIDs: nil,
PolicyIDs: []uint{createPol1.Policy.ID, createPol2.Policy.ID, createPol3.Policy.ID},
}, http.StatusOK, &tmResp)
pfs, err = s.ds.OutdatedAutomationBatch(ctx)
require.NoError(s.T(), err)
require.Len(s.T(), pfs, 2)
s.DoJSON("POST", "/api/latest/fleet/automations/reset", resetAutomationRequest{
TeamIDs: []uint{team1.ID},
PolicyIDs: nil,
}, http.StatusOK, &tmResp)
pfs, err = s.ds.OutdatedAutomationBatch(ctx)
require.NoError(s.T(), err)
require.Len(s.T(), pfs, 2)
s.DoJSON("POST", "/api/latest/fleet/automations/reset", resetAutomationRequest{
TeamIDs: nil,
PolicyIDs: []uint{createPol2.Policy.ID},
}, http.StatusOK, &tmResp)
pfs, err = s.ds.OutdatedAutomationBatch(ctx)
require.NoError(s.T(), err)
require.Len(s.T(), pfs, 1)
}
// allEqual compares all fields of a struct.
// If a field is a pointer on one side but not on the other, then it follows that pointer. This is useful for optional
// arguments.
func allEqual(t *testing.T, expect, actual interface{}, fields ...string) {
require.NotEmpty(t, fields)
t.Helper()
expV := reflect.Indirect(reflect.ValueOf(expect))
actV := reflect.Indirect(reflect.ValueOf(actual))
for _, f := range fields {
e, a := expV.FieldByName(f), actV.FieldByName(f)
switch {
case e.Kind() == reflect.Ptr && a.Kind() != reflect.Ptr && !e.IsZero():
e = e.Elem()
case a.Kind() == reflect.Ptr && e.Kind() != reflect.Ptr && !a.IsZero():
a = a.Elem()
}
require.Equal(t, e.Interface(), a.Interface(), "%s", f)
}
}
func createHostAndDeviceToken(t *testing.T, ds *mysql.Datastore, token string) *fleet.Host {
host, err := ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
HardwareSerial: uuid.New().String(),
Platform: "darwin",
})
require.NoError(t, err)
createDeviceTokenForHost(t, ds, host.ID, token)
return host
}
func updateDeviceTokenForHost(t *testing.T, ds *mysql.Datastore, hostID uint, token string) {
mysql.ExecAdhocSQL(t, ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `UPDATE host_device_auth SET token = ? WHERE host_id = ?`, token, hostID)
return err
})
}
func createDeviceTokenForHost(t *testing.T, ds *mysql.Datastore, hostID uint, token string) {
mysql.ExecAdhocSQL(t, ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, hostID, token)
return err
})
}
func (s *integrationEnterpriseTestSuite) TestListSoftware() {
t := s.T()
now := time.Now().UTC().Truncate(time.Second)
ctx := context.Background()
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(t.Name() + "1"),
UUID: t.Name() + "1",
Hostname: t.Name() + "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
})
require.NoError(t, err)
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "apps"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, host, false))
bar := host.Software[0]
if bar.Name != "bar" {
bar = host.Software[1]
}
inserted, err := s.ds.InsertSoftwareVulnerability(
ctx, fleet.SoftwareVulnerability{
SoftwareID: bar.ID,
CVE: "cve-123",
ResolvedInVersion: ptr.String("1.2.3"),
}, fleet.NVDSource,
)
require.NoError(t, err)
require.True(t, inserted)
require.NoError(t, s.ds.InsertCVEMeta(ctx, []fleet.CVEMeta{{
CVE: "cve-123",
CVSSScore: ptr.Float64(5.4),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
Published: &now,
Description: "a long description of the cve",
}}))
require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now().UTC()))
var resp listSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software", nil, http.StatusOK, &resp)
require.NotNil(t, resp)
var fooPayload, barPayload fleet.Software
for _, s := range resp.Software {
switch s.Name {
case "foo":
fooPayload = s
case "bar":
barPayload = s
default:
require.Failf(t, "unrecognized software %s", s.Name)
}
}
require.Empty(t, fooPayload.Vulnerabilities)
require.Len(t, barPayload.Vulnerabilities, 1)
require.Equal(t, barPayload.Vulnerabilities[0].CVE, "cve-123")
require.NotNil(t, barPayload.Vulnerabilities[0].CVSSScore, ptr.Float64Ptr(5.4))
require.NotNil(t, barPayload.Vulnerabilities[0].EPSSProbability, ptr.Float64Ptr(0.5))
require.NotNil(t, barPayload.Vulnerabilities[0].CISAKnownExploit, ptr.BoolPtr(true))
require.Equal(t, barPayload.Vulnerabilities[0].CVEPublished, ptr.TimePtr(now))
require.Equal(t, barPayload.Vulnerabilities[0].Description, ptr.StringPtr("a long description of the cve"))
require.Equal(t, barPayload.Vulnerabilities[0].ResolvedInVersion, ptr.StringPtr("1.2.3"))
var respVersions listSoftwareVersionsResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", nil, http.StatusOK, &respVersions)
require.NotNil(t, resp)
for _, s := range resp.Software {
switch s.Name {
case "foo":
fooPayload = s
case "bar":
barPayload = s
default:
require.Failf(t, "unrecognized software %s", s.Name)
}
}
require.Empty(t, fooPayload.Vulnerabilities)
require.Len(t, barPayload.Vulnerabilities, 1)
require.Equal(t, barPayload.Vulnerabilities[0].CVE, "cve-123")
require.NotNil(t, barPayload.Vulnerabilities[0].CVSSScore, ptr.Float64Ptr(5.4))
require.NotNil(t, barPayload.Vulnerabilities[0].EPSSProbability, ptr.Float64Ptr(0.5))
require.NotNil(t, barPayload.Vulnerabilities[0].CISAKnownExploit, ptr.BoolPtr(true))
require.Equal(t, barPayload.Vulnerabilities[0].CVEPublished, ptr.TimePtr(now))
require.Equal(t, barPayload.Vulnerabilities[0].Description, ptr.StringPtr("a long description of the cve"))
require.Equal(t, barPayload.Vulnerabilities[0].ResolvedInVersion, ptr.StringPtr("1.2.3"))
// vulnerable param required when using vulnerability filters
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"without_vulnerability_details", "true",
)
for _, s := range respVersions.Software {
for _, cve := range s.Vulnerabilities {
require.Nil(t, cve.CVSSScore)
require.Nil(t, cve.EPSSProbability)
require.Nil(t, cve.CISAKnownExploit)
require.Nil(t, cve.CVEPublished)
require.Nil(t, cve.Description)
require.Nil(t, cve.ResolvedInVersion)
}
}
// without_vulnerability_details with vulnerability filter
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"exploit", "true",
"vulnerable", "true",
"without_vulnerability_details", "true",
)
for _, s := range respVersions.Software {
for _, cve := range s.Vulnerabilities {
require.Nil(t, cve.CVSSScore)
require.Nil(t, cve.EPSSProbability)
require.Nil(t, cve.CISAKnownExploit)
require.Nil(t, cve.CVEPublished)
require.Nil(t, cve.Description)
require.Nil(t, cve.ResolvedInVersion)
}
}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusUnprocessableEntity, &respVersions,
"exploit", "true",
)
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusUnprocessableEntity, &respVersions,
"min_cvss_score", "1.1",
)
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusUnprocessableEntity, &respVersions,
"max_cvss_score", "10.0",
)
// vulnerability filters
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"exploit", "true",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 1)
require.NotEmpty(t, respVersions.CountsUpdatedAt)
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"min_cvss_score", "1",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 1)
require.NotEmpty(t, respVersions.CountsUpdatedAt)
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"min_cvss_score", "10",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 0)
require.Nil(t, respVersions.CountsUpdatedAt)
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"max_cvss_score", "10",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 1)
require.NotEmpty(t, respVersions.CountsUpdatedAt)
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"max_cvss_score", "1",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 0)
require.Nil(t, respVersions.CountsUpdatedAt)
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"min_cvss_score", "1",
"max_cvss_score", "10",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 1)
require.NotEmpty(t, respVersions.CountsUpdatedAt)
respVersions = listSoftwareVersionsResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/versions",
listSoftwareRequest{},
http.StatusOK, &respVersions,
"min_cvss_score", "1",
"max_cvss_score", "10",
"exploit", "true",
"vulnerable", "true",
)
require.Len(t, respVersions.Software, 1)
require.NotEmpty(t, respVersions.CountsUpdatedAt)
}
// TestGitOpsUserActions tests the MDM permissions listed in ../../docs/Using\ Fleet/manage-access.md
func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() {
t := s.T()
ctx := context.Background()
//
// Setup test data.
// All setup actions are authored by a global admin.
//
admin, err := s.ds.UserByEmail(ctx, "admin1@example.com")
require.NoError(t, err)
h1, err := s.ds.NewHost(ctx, &fleet.Host{
NodeKey: ptr.String(t.Name() + "1"),
UUID: t.Name() + "1",
Hostname: strings.ReplaceAll(t.Name()+"foo.local", "/", "_"),
})
require.NoError(t, err)
t1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Foo",
})
require.NoError(t, err)
t2, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Bar",
})
require.NoError(t, err)
t3, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Zoo",
})
require.NoError(t, err)
team1Host, err := s.ds.NewHost(ctx, &fleet.Host{
NodeKey: ptr.String(t.Name() + "2"),
UUID: t.Name() + "2",
Hostname: strings.ReplaceAll(t.Name()+"zoo.local", "/", "_"),
TeamID: &t1.ID,
})
require.NoError(t, err)
globalHost, err := s.ds.NewHost(ctx, &fleet.Host{
NodeKey: ptr.String(t.Name() + "3"),
UUID: t.Name() + "3",
Hostname: strings.ReplaceAll(t.Name()+"global.local", "/", "_"),
})
require.NoError(t, err)
acr := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"webhook_settings": {
"vulnerabilities_webhook": {
"enable_vulnerabilities_webhook": false
}
}
}`), http.StatusOK, &acr)
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acr)
require.False(t, acr.WebhookSettings.VulnerabilitiesWebhook.Enable)
q1, err := s.ds.NewQuery(ctx, &fleet.Query{
Name: "Foo",
Query: "SELECT * from time;",
Logging: fleet.LoggingSnapshot,
})
require.NoError(t, err)
ggsr := getGlobalScheduleResponse{}
s.DoJSON("GET", "/api/latest/fleet/schedule", nil, http.StatusOK, &ggsr)
require.NoError(t, ggsr.Err)
cpar := createPackResponse{}
var userPackID uint
s.DoJSON("POST", "/api/latest/fleet/packs", createPackRequest{
PackPayload: fleet.PackPayload{
Name: ptr.String("Foobar"),
Disabled: ptr.Bool(false),
},
}, http.StatusOK, &cpar)
userPackID = cpar.Pack.Pack.ID
require.NotZero(t, userPackID)
cur := createUserResponse{}
s.DoJSON("POST", "/api/latest/fleet/users/admin", createUserRequest{
UserPayload: fleet.UserPayload{
Email: ptr.String("foo42@example.com"),
Password: ptr.String("p4ssw0rd.123"),
Name: ptr.String("foo42"),
GlobalRole: ptr.String("maintainer"),
},
}, http.StatusOK, &cur)
maintainer := cur.User
var carveBeginResp carveBeginResponse
s.DoJSON("POST", "/api/osquery/carve/begin", carveBeginRequest{
NodeKey: *h1.NodeKey,
BlockCount: 3,
BlockSize: 3,
CarveSize: 8,
CarveId: "c1",
RequestId: "r1",
}, http.StatusOK, &carveBeginResp)
require.NotEmpty(t, carveBeginResp.SessionId)
lcr := listCarvesResponse{}
s.DoJSON("GET", "/api/latest/fleet/carves", listCarvesRequest{}, http.StatusOK, &lcr)
require.NotEmpty(t, lcr.Carves)
carveID := lcr.Carves[0].ID
// Create the global GitOps user we'll use in tests.
u := &fleet.User{
Name: "GitOps",
Email: "gitops1@example.com",
GlobalRole: ptr.String(fleet.RoleGitOps),
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
// Create a GitOps user for team t1 we'll use in tests.
u2 := &fleet.User{
Name: "GitOps 2",
Email: "gitops2@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *t1,
Role: fleet.RoleGitOps,
},
{
Team: *t3,
Role: fleet.RoleGitOps,
},
},
}
require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(context.Background(), u2)
require.NoError(t, err)
gp2, err := s.ds.NewGlobalPolicy(ctx, &admin.ID, fleet.PolicyPayload{
Name: "Zoo",
Query: "SELECT 0;",
})
require.NoError(t, err)
t2p, err := s.ds.NewTeamPolicy(ctx, t2.ID, &admin.ID, fleet.PolicyPayload{
Name: "Zoo2",
Query: "SELECT 2;",
})
require.NoError(t, err)
// Create some test user to test moving from/to teams.
u3 := &fleet.User{
Name: "Test Foo Observer",
Email: "test-foo-observer@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *t1,
Role: fleet.RoleObserver,
},
},
}
require.NoError(t, u3.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(context.Background(), u3)
require.NoError(t, err)
manualLabel1, err := s.ds.NewLabel(ctx, &fleet.Label{
Name: "manualLabel1",
Query: "SELECT 2;",
LabelMembershipType: fleet.LabelMembershipTypeManual,
})
require.NoError(t, err)
//
// Start running permission tests with user gitops1.
//
s.setTokenForTest(t, "gitops1@example.com", test.GoodPassword)
// Attempt to retrieve activities, should fail.
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusForbidden, &listActivitiesResponse{})
// Attempt to retrieve hosts, should fail.
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusForbidden, &listHostsResponse{})
// Attempt to retrieve a host by identifier should succeed
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", h1.Hostname), hostByIdentifierRequest{}, http.StatusOK, &getHostResponse{})
// Attempt to filter hosts using labels, should fail (label ID 6 is the builtin label "All Hosts")
s.DoJSON("GET", "/api/latest/fleet/labels/6/hosts", nil, http.StatusOK, &listHostsResponse{})
// Attempt to delete hosts, should fail.
s.DoJSON("DELETE", "/api/latest/fleet/hosts/1", nil, http.StatusForbidden, &deleteHostResponse{})
// Attempt to transfer host from global to a team, should allow.
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &t1.ID,
HostIDs: []uint{h1.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
// Attempt to create a label, should allow.
clr := createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: "foo",
Query: "SELECT 1;",
},
}, http.StatusOK, &clr)
// Attempt to modify a label, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", clr.Label.ID), modifyLabelRequest{
ModifyLabelPayload: fleet.ModifyLabelPayload{
Name: ptr.String("foo2"),
},
}, http.StatusOK, &modifyLabelResponse{})
// Attempt to get a label, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", clr.Label.ID), getLabelRequest{}, http.StatusOK, &getLabelResponse{})
// Attempt to list all labels, should fail.
s.DoJSON("GET", "/api/latest/fleet/labels", listLabelsRequest{}, http.StatusOK, &listLabelsResponse{})
// Attempt to delete a label, should allow.
s.DoJSON("DELETE", "/api/latest/fleet/labels/foo2", deleteLabelRequest{}, http.StatusOK, &deleteLabelResponse{})
// Attempt to list all software, should fail.
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareRequest{}, http.StatusForbidden, &listSoftwareVersionsResponse{})
s.DoJSON("GET", "/api/latest/fleet/software", listSoftwareRequest{}, http.StatusForbidden, &listSoftwareResponse{})
s.DoJSON("GET", "/api/latest/fleet/software/count", countSoftwareRequest{}, http.StatusForbidden, &countSoftwareResponse{})
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusForbidden, &listSoftwareTitlesResponse{})
s.DoJSON("GET", "/api/latest/fleet/software/titles/1", getSoftwareTitleRequest{}, http.StatusForbidden, &getSoftwareTitleResponse{})
// Attempt to list a software, should fail.
s.DoJSON("GET", "/api/latest/fleet/software/1", getSoftwareRequest{}, http.StatusForbidden, &getSoftwareResponse{})
s.DoJSON("GET", "/api/latest/fleet/software/versions/1", getSoftwareRequest{}, http.StatusForbidden, &getSoftwareResponse{})
// Attempt to read app config, should pass.
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &appConfigResponse{})
// Attempt to write app config, should allow.
acr = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"webhook_settings": {
"vulnerabilities_webhook": {
"enable_vulnerabilities_webhook": true,
"destination_url": "https://foobar.example.com"
}
}
}`), http.StatusOK, &acr)
require.True(t, acr.AppConfig.WebhookSettings.VulnerabilitiesWebhook.Enable)
require.Equal(t, "https://foobar.example.com", acr.AppConfig.WebhookSettings.VulnerabilitiesWebhook.DestinationURL)
// Attempt to add/remove manual labels to/from a host.
var addLabelsToHostResp addLabelsToHostResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", h1.ID), addLabelsToHostRequest{
Labels: []string{manualLabel1.Name},
}, http.StatusOK, &addLabelsToHostResp)
var removeLabelsFromHostResp removeLabelsFromHostResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", h1.ID), removeLabelsFromHostRequest{
Labels: []string{manualLabel1.Name},
}, http.StatusOK, &removeLabelsFromHostResp)
// Attempt to run live queries synchronously, should fail.
s.DoJSON("GET", "/api/latest/fleet/queries/run", runLiveQueryRequest{
HostIDs: []uint{h1.ID},
QueryIDs: []uint{q1.ID},
}, http.StatusForbidden, &runLiveQueryResponse{},
)
// Attempt to run live queries asynchronously (new unsaved query), should fail.
s.DoJSON("POST", "/api/latest/fleet/queries/run", createDistributedQueryCampaignRequest{
QuerySQL: "SELECT * FROM time;",
Selected: fleet.HostTargets{
HostIDs: []uint{h1.ID},
},
}, http.StatusForbidden, &runLiveQueryResponse{})
// Attempt to run live queries asynchronously (saved query), should fail.
s.DoJSON("POST", "/api/latest/fleet/queries/run", createDistributedQueryCampaignRequest{
QueryID: ptr.Uint(q1.ID),
Selected: fleet.HostTargets{
HostIDs: []uint{h1.ID},
},
}, http.StatusForbidden, &runLiveQueryResponse{})
// Attempt to create queries, should allow.
cqr := createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo4"),
Query: ptr.String("SELECT * from osquery_info;"),
},
}, http.StatusOK, &cqr)
cqr2 := createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo5"),
Query: ptr.String("SELECT * from os_version;"),
},
}, http.StatusOK, &cqr2)
cqr3 := createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo6"),
Query: ptr.String("SELECT * from processes;"),
},
}, http.StatusOK, &cqr3)
cqr4 := createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo7"),
Query: ptr.String("SELECT * from managed_policies;"),
},
}, http.StatusOK, &cqr4)
// Attempt to edit queries, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", cqr.Query.ID), modifyQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo4"),
Query: ptr.String("SELECT * FROM system_info;"),
},
}, http.StatusOK, &modifyQueryResponse{})
// Attempt to view a query, should work.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d", cqr.Query.ID), getQueryRequest{}, http.StatusOK, &getQueryResponse{})
// Attempt to list all queries, should work.
s.DoJSON("GET", "/api/latest/fleet/queries", listQueriesRequest{}, http.StatusOK, &listQueriesResponse{})
// Attempt to delete queries, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/id/%d", cqr.Query.ID), deleteQueryByIDRequest{}, http.StatusOK, &deleteQueryByIDResponse{})
s.DoJSON("POST", "/api/latest/fleet/queries/delete", deleteQueriesRequest{IDs: []uint{cqr2.Query.ID}}, http.StatusOK, &deleteQueriesResponse{})
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/%s", cqr3.Query.Name), deleteQueryRequest{}, http.StatusOK, &deleteQueryResponse{})
// Attempt to add a query to a user pack, should allow.
sqr := scheduleQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/packs/schedule", scheduleQueryRequest{
PackID: userPackID,
QueryID: cqr4.Query.ID,
Interval: 60,
}, http.StatusOK, &sqr)
// Attempt to edit a scheduled query in the global schedule, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/packs/schedule/%d", sqr.Scheduled.ID), modifyScheduledQueryRequest{
ScheduledQueryPayload: fleet.ScheduledQueryPayload{
Interval: ptr.Uint(30),
},
}, http.StatusOK, &scheduleQueryResponse{})
// Attempt to remove a query from the global schedule, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/packs/schedule/%d", sqr.Scheduled.ID), deleteScheduledQueryRequest{}, http.StatusOK, &scheduleQueryResponse{})
// Attempt to read the global schedule, should allow.
s.DoJSON("GET", "/api/latest/fleet/schedule", nil, http.StatusOK, &getGlobalScheduleResponse{})
// Attempt to create a pack, should allow.
cpr := createPackResponse{}
s.DoJSON("POST", "/api/latest/fleet/packs", createPackRequest{
PackPayload: fleet.PackPayload{
Name: ptr.String("foo8"),
},
}, http.StatusOK, &cpr)
// Attempt to edit a pack, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/packs/%d", cpr.Pack.ID), modifyPackRequest{
PackPayload: fleet.PackPayload{
Name: ptr.String("foo9"),
},
}, http.StatusOK, &modifyPackResponse{})
// Attempt to read a pack, should allow.
// This is an exception to the "write only" nature of gitops (packs can be viewed by gitops).
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/packs/%d", cpr.Pack.ID), nil, http.StatusOK, &getPackResponse{})
// Attempt to delete a pack, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/packs/id/%d", cpr.Pack.ID), deletePackRequest{}, http.StatusOK, &deletePackResponse{})
// Attempt to create a global policy, should allow.
gplr := globalPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/policies", globalPolicyRequest{
Name: "foo9",
Query: "SELECT * from plist;",
}, http.StatusOK, &gplr)
// Attempt to edit a global policy, should allow.
mgplr := modifyGlobalPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/policies/%d", gplr.Policy.ID), modifyGlobalPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Query: ptr.String("SELECT * from plist WHERE path = 'foo';"),
},
}, http.StatusOK, &mgplr)
// Attempt to read a global policy, should allow.
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/policies/%d", gplr.Policy.ID), getPolicyByIDRequest{}, http.StatusOK,
&getPolicyByIDResponse{},
)
// Attempt to delete a global policy, should allow.
s.DoJSON("POST", "/api/latest/fleet/policies/delete", deleteGlobalPoliciesRequest{
IDs: []uint{gplr.Policy.ID},
}, http.StatusOK, &deleteGlobalPoliciesResponse{})
// Attempt to create a team policy, should allow.
tplr := teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/team/%d/policies", t1.ID), teamPolicyRequest{
Name: "foo10",
Query: "SELECT * from file;",
}, http.StatusOK, &tplr)
// Attempt to edit a team policy, should allow.
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", t1.ID, tplr.Policy.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Query: ptr.String("SELECT * from file WHERE path = 'foo';"),
},
}, http.StatusOK, &mtplr)
// Attempt to view a team policy, should allow.
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/team/%d/policies/%d", t1.ID, tplr.Policy.ID), getTeamPolicyByIDRequest{}, http.StatusOK,
&getTeamPolicyByIDResponse{},
)
// Attempt to delete a team policy, should allow.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", t1.ID), deleteTeamPoliciesRequest{
IDs: []uint{tplr.Policy.ID},
}, http.StatusOK, &deleteTeamPoliciesResponse{})
// Attempt to create a user, should fail.
s.DoJSON("POST", "/api/latest/fleet/users/admin", createUserRequest{
UserPayload: fleet.UserPayload{
Email: ptr.String("foo10@example.com"),
Name: ptr.String("foo10"),
GlobalRole: ptr.String("admin"),
},
}, http.StatusForbidden, &createUserResponse{})
// Attempt to modify a user, should fail.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/users/%d", admin.ID), modifyUserRequest{
UserPayload: fleet.UserPayload{
GlobalRole: ptr.String("observer"),
},
}, http.StatusForbidden, &modifyUserResponse{})
// Attempt to view a user, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", admin.ID), getUserRequest{}, http.StatusForbidden, &getUserResponse{})
// Attempt to delete a user, should fail.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/users/%d", admin.ID), deleteUserRequest{}, http.StatusForbidden, &deleteUserResponse{})
// Attempt to add users to team, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/users", t1.ID), modifyTeamUsersRequest{
Users: []fleet.TeamUser{
{
User: *maintainer,
Role: "admin",
},
},
}, http.StatusOK, &teamResponse{})
// Attempt to delete users from team, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/users", t1.ID), modifyTeamUsersRequest{
Users: []fleet.TeamUser{
{
User: *maintainer,
Role: "admin",
},
},
}, http.StatusOK, &teamResponse{})
// Attempt to create a team, should allow.
tr := teamResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String("foo11"),
},
}, http.StatusOK, &tr)
// Attempt to edit a team, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tr.Team.ID), modifyTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String("foo12"),
},
}, http.StatusOK, &teamResponse{})
// Attempt to edit a team's agent options, should allow.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", tr.Team.ID), json.RawMessage(`{
"config": {
"options": {
"aws_debug": true
}
}
}`), http.StatusOK, &teamResponse{})
// Attempt to view a team, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", tr.Team.ID), getTeamRequest{}, http.StatusForbidden, &teamResponse{})
// Attempt to delete a team, should allow.
dtr := deleteTeamResponse{}
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tr.Team.ID), deleteTeamRequest{}, http.StatusOK, &dtr)
// Attempt to create/edit enroll secrets, should allow.
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{
{
Secret: "foo400",
TeamID: nil,
},
{
Secret: "foo500",
TeamID: ptr.Uint(t1.ID),
},
},
},
}, http.StatusOK, &applyEnrollSecretSpecResponse{})
// Attempt to get enroll secrets, should fail.
s.DoJSON("GET", "/api/latest/fleet/spec/enroll_secret", nil, http.StatusForbidden, &getEnrollSecretSpecResponse{})
// Attempt to get team enroll secret, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", t1.ID), teamEnrollSecretsRequest{}, http.StatusForbidden, &teamEnrollSecretsResponse{})
// Attempt to list carved files, should fail.
s.DoJSON("GET", "/api/latest/fleet/carves", listCarvesRequest{}, http.StatusForbidden, &listCarvesResponse{})
// Attempt to get a carved file, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/carves/%d", carveID), listCarvesRequest{}, http.StatusForbidden, &listCarvesResponse{})
// Attempt to search hosts, should fail.
s.DoJSON("POST", "/api/latest/fleet/targets", searchTargetsRequest{
MatchQuery: "foo",
QueryID: &q1.ID,
}, http.StatusForbidden, &searchTargetsResponse{})
// Attempt to count target hosts, should fail.
s.DoJSON("POST", "/api/latest/fleet/targets/count", countTargetsRequest{
Selected: fleet.HostTargets{
HostIDs: []uint{h1.ID},
LabelIDs: []uint{clr.Label.ID},
TeamIDs: []uint{t1.ID},
},
QueryID: &q1.ID,
}, http.StatusForbidden, &countTargetsResponse{})
//
// Start running permission tests with user gitops2 (which is a GitOps use for team t1).
//
s.setTokenForTest(t, "gitops2@example.com", test.GoodPassword)
// Attempt to create queries in global domain, should fail.
tcqr := createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo600"),
Query: ptr.String("SELECT * from orbit_info;"),
},
}, http.StatusForbidden, &tcqr)
// Attempt to create queries in its team, should allow.
tcqr = createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo600"),
Query: ptr.String("SELECT * from orbit_info;"),
TeamID: &t1.ID,
},
}, http.StatusOK, &tcqr)
// Attempt to edit own query, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", tcqr.Query.ID), modifyQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo4"),
Query: ptr.String("SELECT * FROM system_info;"),
},
}, http.StatusOK, &modifyQueryResponse{})
// Attempt to delete own query, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/id/%d", tcqr.Query.ID), deleteQueryByIDRequest{}, http.StatusOK, &deleteQueryByIDResponse{})
// Attempt to edit query created by somebody else, should fail.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", cqr4.Query.ID), modifyQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo4"),
Query: ptr.String("SELECT * FROM system_info;"),
},
}, http.StatusForbidden, &modifyQueryResponse{})
// Attempt to delete query created by somebody else, should fail.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/id/%d", cqr4.Query.ID), deleteQueryByIDRequest{}, http.StatusForbidden, &deleteQueryByIDResponse{})
// Attempt to read the global schedule, should fail.
s.DoJSON("GET", "/api/latest/fleet/schedule", nil, http.StatusForbidden, &getGlobalScheduleResponse{})
// Attempt to read the team's schedule, should allow.
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", t1.ID), getTeamScheduleRequest{}, http.StatusOK,
&getTeamScheduleResponse{},
)
// Attempt to read other team's schedule, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", t2.ID), getTeamScheduleRequest{}, http.StatusForbidden, &getTeamScheduleResponse{})
// Attempt to add a query to a user pack, should fail.
tsqr := scheduleQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/packs/schedule", scheduleQueryRequest{
PackID: userPackID,
QueryID: cqr4.Query.ID,
Interval: 60,
}, http.StatusForbidden, &tsqr)
// Attempt to add a query to the team's schedule, should allow.
cqrt1 := createQueryResponse{}
s.DoJSON("POST", "/api/latest/fleet/queries", createQueryRequest{
QueryPayload: fleet.QueryPayload{
Name: ptr.String("foo8"),
Query: ptr.String("SELECT * from managed_policies;"),
TeamID: &t1.ID,
},
}, http.StatusOK, &cqrt1)
ttsqr := teamScheduleQueryResponse{}
// Add a schedule with the deprecated APIs (by referencing a global query).
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule", t1.ID), teamScheduleQueryRequest{
ScheduledQueryPayload: fleet.ScheduledQueryPayload{
QueryID: ptr.Uint(q1.ID),
Interval: ptr.Uint(60),
},
}, http.StatusOK, &ttsqr)
// Attempt to remove a query from the team's schedule, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/schedule/%d", t1.ID, ttsqr.Scheduled.ID), deleteTeamScheduleRequest{}, http.StatusOK, &deleteTeamScheduleResponse{})
// Attempt to add/remove a manual label from a team host, should allow.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", team1Host.ID), addLabelsToHostRequest{
Labels: []string{manualLabel1.Name},
}, http.StatusOK, &addLabelsToHostResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", team1Host.ID), removeLabelsFromHostRequest{
Labels: []string{manualLabel1.Name},
}, http.StatusOK, &removeLabelsFromHostResp)
// Attempt to add/remove a manual label from a global host, should not allow.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), addLabelsToHostRequest{
Labels: []string{manualLabel1.Name},
}, http.StatusForbidden, &addLabelsToHostResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", globalHost.ID), removeLabelsFromHostRequest{
Labels: []string{manualLabel1.Name},
}, http.StatusForbidden, &removeLabelsFromHostResp)
// Attempt to read the global schedule, should fail.
s.DoJSON("GET", "/api/latest/fleet/schedule", nil, http.StatusForbidden, &getGlobalScheduleResponse{})
// Attempt to read a global policy, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/policies/%d", gp2.ID), getPolicyByIDRequest{}, http.StatusForbidden, &getPolicyByIDResponse{})
// Attempt to delete a global policy, should fail.
s.DoJSON("POST", "/api/latest/fleet/policies/delete", deleteGlobalPoliciesRequest{
IDs: []uint{gp2.ID},
}, http.StatusForbidden, &deleteGlobalPoliciesResponse{})
// Attempt to create a team policy, should allow.
ttplr := teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/team/%d/policies", t1.ID), teamPolicyRequest{
Name: "foo1000",
Query: "SELECT * from file;",
}, http.StatusOK, &ttplr)
// Attempt to edit a team policy, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", t1.ID, ttplr.Policy.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Query: ptr.String("SELECT * from file WHERE path = 'foobar';"),
},
}, http.StatusOK, &modifyTeamPolicyResponse{})
// Attempt to edit another team's policy, should fail.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", t2.ID, t2p.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
Query: ptr.String("SELECT * from file WHERE path = 'foobar';"),
},
}, http.StatusForbidden, &modifyTeamPolicyResponse{})
// Attempt to view a team policy, should allow.
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/team/%d/policies/%d", t1.ID, ttplr.Policy.ID), getTeamPolicyByIDRequest{}, http.StatusOK,
&getTeamPolicyByIDResponse{},
)
// Attempt to view another team's policy, should fail.
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/team/%d/policies/%d", t2.ID, t2p.ID), getTeamPolicyByIDRequest{}, http.StatusForbidden, &getTeamPolicyByIDResponse{})
// Attempt to delete a team policy, should allow.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", t1.ID), deleteTeamPoliciesRequest{
IDs: []uint{ttplr.Policy.ID},
}, http.StatusOK, &deleteTeamPoliciesResponse{})
// Attempt to edit own team, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", t1.ID), modifyTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String("foo123456"),
},
}, http.StatusOK, &teamResponse{})
// Attempt to edit another team, should fail.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", t2.ID), modifyTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String("foo123456"),
},
}, http.StatusForbidden, &teamResponse{})
// Attempt to edit own team's agent options, should allow.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", t1.ID), json.RawMessage(`{
"config": {
"options": {
"aws_debug": true
}
}
}`), http.StatusOK, &teamResponse{})
// Attempt to edit another team's agent options, should fail.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/agent_options", t2.ID), json.RawMessage(`{
"config": {
"options": {
"aws_debug": true
}
}
}`), http.StatusForbidden, &teamResponse{})
// Attempt to add users from team it owns to another team it owns, should allow.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/users", t3.ID), modifyTeamUsersRequest{
Users: []fleet.TeamUser{
{
User: *u3,
Role: "maintainer",
},
},
}, http.StatusOK, &teamResponse{})
// Attempt to delete users from team it owns, should allow.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/users", t3.ID), modifyTeamUsersRequest{
Users: []fleet.TeamUser{
{
User: *u3,
},
},
}, http.StatusOK, &teamResponse{})
// Attempt to add users to another team it doesn't own, should fail.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/users", t2.ID), modifyTeamUsersRequest{
Users: []fleet.TeamUser{
{
User: *u3,
Role: "maintainer",
},
},
}, http.StatusForbidden, &teamResponse{})
// Attempt to delete users from team it doesn't own, should fail.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d/users", t2.ID), modifyTeamUsersRequest{
Users: []fleet.TeamUser{
{
User: *u2,
},
},
}, http.StatusForbidden, &teamResponse{})
// Attempt to search hosts, should fail.
s.DoJSON("POST", "/api/latest/fleet/targets", searchTargetsRequest{
MatchQuery: "foo",
QueryID: &q1.ID,
}, http.StatusForbidden, &searchTargetsResponse{})
// Attempt to count target hosts, should fail.
s.DoJSON("POST", "/api/latest/fleet/targets/count", countTargetsRequest{
Selected: fleet.HostTargets{
HostIDs: []uint{h1.ID},
LabelIDs: []uint{clr.Label.ID},
TeamIDs: []uint{t1.ID},
},
QueryID: &q1.ID,
}, http.StatusForbidden, &countTargetsResponse{})
}
func (s *integrationEnterpriseTestSuite) TestDesktopEndpointWithInvalidPolicy() {
t := s.T()
token := "abcd123"
host := createHostAndDeviceToken(t, s.ds, token)
// Create an 'invalid' global policy for host
admin := s.users["admin1@example.com"]
err := s.ds.SaveUser(context.Background(), &admin)
require.NoError(t, err)
policy, err := s.ds.NewGlobalPolicy(context.Background(), &admin.ID, fleet.PolicyPayload{
Query: "SELECT 1 FROM table",
Name: "test",
Description: "Some invalid Query",
Resolution: "",
Platform: host.Platform,
Critical: false,
})
require.NoError(t, err)
require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{policy.ID: nil}, time.Now(), false))
// Any 'invalid' policies should be ignored.
desktopRes := fleetDesktopResponse{}
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&desktopRes))
require.NoError(t, res.Body.Close())
require.NoError(t, desktopRes.Err)
require.Equal(t, uint(0), *desktopRes.FailingPolicies)
}
func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
t := s.T()
testRunScriptWaitForResult = 2 * time.Second
defer func() { testRunScriptWaitForResult = 0 }()
ctx := context.Background()
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
otherHost := createOrbitEnrolledHost(t, "linux", "other", s.ds)
// attempt to run a script on a non-existing host
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptContents: "echo"}, http.StatusNotFound, &runResp)
// attempt to run an empty script
res := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: ""}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Validation Failed: One of 'script_id', 'script_contents', or 'script_name' is required.")
// attempt to run an overly long script
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", fleet.UnsavedScriptMaxRuneLen+1)}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script is too large. It's limited to 10,000 characters")
// make sure the host is still seen as "online"
err := s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
require.NoError(t, err)
// make sure invalid secrets aren't allowed
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo $FLEET_SECRET_INVALID"}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `$FLEET_SECRET_INVALID`)
// Upload a valid secret
secretValue := "abc123"
req := createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_TEST_RUN_HOST_SCRIPT",
Value: secretValue,
},
},
}
secretResp := createSecretVariablesResponse{}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
// create a valid script execution request
expectedScriptContents := "echo ${FLEET_SECRET_TEST_RUN_HOST_SCRIPT}"
expectedScriptContentsWithSecret := fmt.Sprintf("echo %s", secretValue)
s.DoJSON("POST", "/api/latest/fleet/scripts/run",
fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: expectedScriptContents}, http.StatusAccepted, &runResp)
require.Equal(t, host.ID, runResp.HostID)
require.NotEmpty(t, runResp.ExecutionID)
result, err := s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID)
require.NoError(t, err)
require.Equal(t, host.ID, result.HostID)
require.Equal(t, expectedScriptContents, result.ScriptContents)
require.Nil(t, result.ExitCode)
// get script result
var scriptResultResp getScriptResultResponse
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host.ID, scriptResultResp.HostID)
require.Equal(t, expectedScriptContents, scriptResultResp.ScriptContents)
require.Nil(t, scriptResultResp.ExitCode)
require.False(t, scriptResultResp.HostTimeout)
require.Contains(t, scriptResultResp.Message, fleet.RunScriptAsyncScriptEnqueuedMsg)
// an async script doesn't care about timeouts
now := time.Now()
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, `UPDATE host_script_results SET created_at = ? WHERE execution_id = ?`,
now.Add(-1*time.Hour),
runResp.ExecutionID,
)
return err
})
scriptResultResp = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host.ID, scriptResultResp.HostID)
require.Equal(t, expectedScriptContents, scriptResultResp.ScriptContents)
require.Nil(t, scriptResultResp.ExitCode)
require.False(t, scriptResultResp.HostTimeout)
require.Contains(t, scriptResultResp.Message, fleet.RunScriptAsyncScriptEnqueuedMsg)
// Disable scripts and verify that there are no Orbit notifs
acr := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {
"scripts_disabled": true
}
}`), http.StatusOK, &acr)
require.True(t, acr.AppConfig.ServerSettings.ScriptsDisabled)
t.Cleanup(func() {
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {
"scripts_disabled": false
}
}`), http.StatusOK, &acr)
})
var orbitResp orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)),
http.StatusOK, &orbitResp)
require.Empty(t, orbitResp.Notifications.PendingScriptExecutionIDs)
// Verify that endpoints related to scripts are disabled
srResp := s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusForbidden)
assertBodyContains(t, srResp, fleet.RunScriptScriptsDisabledGloballyErrMsg)
srResp = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusForbidden)
assertBodyContains(t, srResp, fleet.RunScriptScriptsDisabledGloballyErrMsg)
acr = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {
"scripts_disabled": false
}
}`), http.StatusOK, &acr)
require.False(t, acr.AppConfig.ServerSettings.ScriptsDisabled)
// verify that orbit would get the notification that it has a script to run
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)),
http.StatusOK, &orbitResp)
require.Equal(t, []string{scriptResultResp.ExecutionID}, orbitResp.Notifications.PendingScriptExecutionIDs)
// the orbit endpoint to get a pending script to execute returns it
var orbitGetScriptResp orbitGetScriptResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/request",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)),
http.StatusOK, &orbitGetScriptResp)
require.Equal(t, host.ID, orbitGetScriptResp.HostID)
require.Equal(t, scriptResultResp.ExecutionID, orbitGetScriptResp.ExecutionID)
require.Equal(t, expectedScriptContentsWithSecret, orbitGetScriptResp.ScriptContents)
// trying to get that script via its execution ID but a different host returns not found
s.DoJSON("POST", "/api/fleet/orbit/scripts/request",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q}`, *otherHost.OrbitNodeKey, scriptResultResp.ExecutionID)),
http.StatusNotFound, &orbitGetScriptResp)
// trying to get an unknown execution id returns not found
s.DoJSON("POST", "/api/fleet/orbit/scripts/request",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID+"no-such")),
http.StatusNotFound, &orbitGetScriptResp)
// attempt to run a sync script on a non-existing host
var runSyncResp runScriptSyncResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptContents: "echo"}, http.StatusNotFound, &runSyncResp)
// attempt to sync run an empty script
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: ""}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "One of 'script_id', 'script_contents', or 'script_name' is required.")
// attempt to sync run an overly long script
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", fleet.UnsavedScriptMaxRuneLen+1)}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script is too large. It's limited to 10,000 characters")
// make sure the host is still seen as "online"
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
require.NoError(t, err)
// attempt to create a valid sync script execution request, fails because the
// host has a pending script execution
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.RunScriptAlreadyRunningErrMsg)
// save a result via the orbit endpoint
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// verify that orbit does not receive any pending script anymore
orbitResp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)),
http.StatusOK, &orbitResp)
require.Empty(t, orbitResp.Notifications.PendingScriptExecutionIDs)
// create a valid sync script execution request, fails because the
// request will time-out waiting for a result.
runSyncResp = runScriptSyncResponse{}
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusRequestTimeout, &runSyncResp)
require.Equal(t, host.ID, runSyncResp.HostID)
require.NotEmpty(t, runSyncResp.ExecutionID)
require.True(t, runSyncResp.HostTimeout)
require.Contains(t, runSyncResp.Message, fleet.RunScriptHostTimeoutErrMsg)
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, runSyncResp.ExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// create a valid sync script execution request, and simulate a result
// arriving before timeout.
testRunScriptWaitForResult = 5 * time.Second
ctx, cancel := context.WithTimeout(ctx, testRunScriptWaitForResult)
defer cancel()
resultsCh := make(chan *fleet.HostScriptResultPayload, 1)
go func() {
for range time.Tick(300 * time.Millisecond) {
pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID, false)
if err != nil {
t.Log(err)
return
}
if len(pending) > 0 {
select {
case <-ctx.Done():
return
case r := <-resultsCh:
r.ExecutionID = pending[0].ExecutionID
// ignoring errors in this goroutine, the HTTP request below will fail if this fails
_, _, err = s.ds.SetHostScriptExecutionResult(ctx, r)
if err != nil {
t.Log(err)
}
}
}
}
}()
// simulate a successful script result
resultsCh <- &fleet.HostScriptResultPayload{
HostID: host.ID,
Output: "ok",
Runtime: 1,
ExitCode: 0,
}
runSyncResp = runScriptSyncResponse{}
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusOK, &runSyncResp)
require.Equal(t, host.ID, runSyncResp.HostID)
require.NotEmpty(t, runSyncResp.ExecutionID)
require.Equal(t, "ok", runSyncResp.Output)
require.NotNil(t, runSyncResp.ExitCode)
require.Equal(t, int64(0), *runSyncResp.ExitCode)
require.False(t, runSyncResp.HostTimeout)
// simulate a scripts disabled result
resultsCh <- &fleet.HostScriptResultPayload{
HostID: host.ID,
Output: "",
Runtime: 0,
ExitCode: -2,
}
runSyncResp = runScriptSyncResponse{}
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusOK, &runSyncResp)
require.Equal(t, host.ID, runSyncResp.HostID)
require.NotEmpty(t, runSyncResp.ExecutionID)
require.Empty(t, runSyncResp.Output)
require.NotNil(t, runSyncResp.ExitCode)
require.Equal(t, int64(-2), *runSyncResp.ExitCode)
require.False(t, runSyncResp.HostTimeout)
require.Contains(t, runSyncResp.Message, "Scripts are disabled")
// create a sync execution request.
runSyncResp = runScriptSyncResponse{}
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusRequestTimeout, &runSyncResp)
// modify the timestamp of the script to simulate an script that has
// been pending for a long time
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(context.Background(), "UPDATE host_script_results SET created_at = ? WHERE execution_id = ?", time.Now().Add(-24*time.Hour), runSyncResp.ExecutionID)
return err
})
// fetch the results for the timed-out script
scriptResultResp = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runSyncResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host.ID, scriptResultResp.HostID)
require.Equal(t, "echo", scriptResultResp.ScriptContents)
require.Nil(t, scriptResultResp.ExitCode)
require.True(t, scriptResultResp.HostTimeout)
require.Contains(t, scriptResultResp.Message, fleet.RunScriptHostTimeoutErrMsg)
// Disable scripts on the host
scriptsEnabled := false
err = s.ds.SetOrUpdateHostOrbitInfo(
context.Background(), host.ID, "1.22.0", sql.NullString{}, sql.NullBool{Bool: scriptsEnabled, Valid: true},
)
require.NoError(t, err)
s.DoJSON(
"POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"},
http.StatusUnprocessableEntity, &runResp,
)
s.DoJSON(
"POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"},
http.StatusUnprocessableEntity, &runResp,
)
// Re-enable scripts on the host
scriptsEnabled = true
err = s.ds.SetOrUpdateHostOrbitInfo(
context.Background(), host.ID, "1.22.0", sql.NullString{}, sql.NullBool{Bool: scriptsEnabled, Valid: true},
)
require.NoError(t, err)
// make the host "offline"
err = s.ds.MarkHostsSeen(context.Background(), []uint{host.ID}, time.Now().Add(-time.Hour))
require.NoError(t, err)
// attempt to create a sync script execution request, fails because the host
// is offline.
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.RunScriptHostOfflineErrMsg)
// attempt to create an async script execution request, succeeds because script is added to queue.
s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted)
// attempt to run a script on a plain osquery host
plainOsqueryHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Minute),
OsqueryHostID: ptr.String("plain-osquery-host"),
NodeKey: ptr.String("plain-osquery-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.local", "plain-osquery-host"),
HardwareSerial: uuid.New().String(),
Platform: "linux",
})
require.NoError(t, err)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: plainOsqueryHost.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), fleet.RunScriptDisabledErrMsg)
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: plainOsqueryHost.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), fleet.RunScriptDisabledErrMsg)
// create a execution request that will return a timeout
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp)
// simulate a host response
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": -1, "output": "script execution error: signal: killed", "timeout": 900}`, *host.OrbitNodeKey, runSyncResp.ExecutionID)),
http.StatusOK, &orbitPostScriptResp)
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runSyncResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host.ID, scriptResultResp.HostID)
require.Equal(t, "echo", scriptResultResp.ScriptContents)
require.Equal(t, int64(-1), *scriptResultResp.ExitCode)
require.Equal(t, "Timeout. Fleet stopped the script after 900 seconds to protect host performance.", scriptResultResp.Message)
require.Equal(t, "script execution error: signal: killed", scriptResultResp.Output)
}
func (s *integrationEnterpriseTestSuite) TestRunBatchScript() {
t := s.T()
ctx := context.Background()
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Team 1",
})
require.NoError(t, err)
host1 := createOrbitEnrolledHost(t, "linux", "host1", s.ds)
host2 := createOrbitEnrolledHost(t, "linux", "host2", s.ds)
host3Team1 := createOrbitEnrolledHost(t, "linux", "host3team1", s.ds)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host3Team1.ID}))
require.NoError(t, err)
script, err := s.ds.NewScript(ctx, &fleet.Script{
Name: "script.sh",
ScriptContents: "echo bonjour",
})
require.NoError(t, err)
var batchRes batchScriptRunResponse
// Team mismatch
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID, host2.ID, host3Team1.ID},
}, http.StatusUnprocessableEntity, &batchRes)
require.Empty(t, batchRes.BatchExecutionID)
// Bad script ID
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: 999999,
HostIDs: []uint{host1.ID},
}, http.StatusNotFound, &batchRes)
require.Empty(t, batchRes.BatchExecutionID)
// verify that no script was queued for orbit
var orbitRespHost1 orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host1.OrbitNodeKey)),
http.StatusOK, &orbitRespHost1)
require.Empty(t, orbitRespHost1.Notifications.PendingScriptExecutionIDs)
// Good request
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID, host2.ID},
}, http.StatusOK, &batchRes)
require.NotEmpty(t, batchRes.BatchExecutionID)
// verify that script was queued for orbit
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host1.OrbitNodeKey)),
http.StatusOK, &orbitRespHost1)
require.Len(t, orbitRespHost1.Notifications.PendingScriptExecutionIDs, 1)
var orbitRespHost2 orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host2.OrbitNodeKey)),
http.StatusOK, &orbitRespHost2)
require.Len(t, orbitRespHost2.Notifications.PendingScriptExecutionIDs, 1)
var hostActivitiesResp listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID),
nil, http.StatusOK, &hostActivitiesResp)
expectedActivityDetail := fmt.Sprintf(`
{
"async": true,
"host_id": %d,
"policy_id": null,
"policy_name": null,
"batch_execution_id": "%s",
"script_execution_id": "%s",
"script_name": "%s",
"host_display_name": "%s"
}
`, host1.ID, batchRes.BatchExecutionID, orbitRespHost1.Notifications.PendingScriptExecutionIDs[0], script.Name, host1.DisplayName())
require.Len(t, hostActivitiesResp.Activities, 1)
require.NotNil(t, hostActivitiesResp.Activities[0].Details)
require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details))
// Check status of the batch execution
var batchStatusResp batchScriptExecutionStatusResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s", batchRes.BatchExecutionID), nil, http.StatusOK, &batchStatusResp)
require.Equal(t, *batchStatusResp.ScriptID, script.ID)
require.Equal(t, *batchStatusResp.NumTargeted, uint(2))
require.Equal(t, *batchStatusResp.NumPending, uint(2))
// Deprecated summary endpoint
var batchSummaryResp batchScriptExecutionSummaryResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/summary/%s", batchRes.BatchExecutionID), nil, http.StatusOK, &batchSummaryResp)
require.Equal(t, batchSummaryResp.ScriptID, script.ID)
require.Equal(t, *batchSummaryResp.NumTargeted, uint(2))
require.Equal(t, *batchSummaryResp.NumPending, uint(2))
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScriptBatch{}.ActivityName(),
fmt.Sprintf(`{"batch_execution_id":"%s", "host_count":2, "script_name":"%s", "team_id":null}`, batchRes.BatchExecutionID, script.Name),
0,
)
var batchPendingHostsResp batchScriptExecutionHostResultsResponse
res := s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s/host-results", batchRes.BatchExecutionID), nil, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Param status is required")
// List pending hosts
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s/host-results?status=pending", batchRes.BatchExecutionID), nil, http.StatusOK, &batchPendingHostsResp)
require.Len(t, batchPendingHostsResp.Hosts, 2)
require.Equal(t, batchPendingHostsResp.Count, uint(2))
require.Equal(t, batchPendingHostsResp.Meta.HasNextResults, false)
require.Equal(t, batchPendingHostsResp.Meta.HasPreviousResults, false)
require.Equal(t, batchPendingHostsResp.Hosts[0].Status, fleet.BatchScriptExecutionPending)
require.Equal(t, batchPendingHostsResp.Hosts[0].DisplayName, host1.ComputerName)
require.Equal(t, batchPendingHostsResp.Hosts[0].ID, host1.ID)
require.Equal(t, batchPendingHostsResp.Hosts[1].Status, fleet.BatchScriptExecutionPending)
require.Equal(t, batchPendingHostsResp.Hosts[1].DisplayName, host2.ComputerName)
require.Equal(t, batchPendingHostsResp.Hosts[1].ID, host2.ID)
// Another request so we can check the list endpoint
var batchRes2 batchScriptRunResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID},
}, http.StatusOK, &batchRes2)
require.NotEmpty(t, batchRes2.BatchExecutionID)
// Check with the list endpoint
var batchListResp batchScriptExecutionListResponse
s.DoJSON("GET", "/api/latest/fleet/scripts/batch?team_id=0&per_page=1", nil, http.StatusOK, &batchListResp)
require.Len(t, batchListResp.BatchScriptExecutions, 1)
require.Equal(t, batchListResp.Count, uint(2))
require.Equal(t, batchListResp.Meta.HasNextResults, true)
require.Equal(t, batchListResp.Meta.HasPreviousResults, false)
require.Equal(t, batchListResp.BatchScriptExecutions[0].BatchExecutionID, batchRes2.BatchExecutionID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].ScriptID, script.ID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].NumTargeted, uint(1))
require.Equal(t, *batchListResp.BatchScriptExecutions[0].NumPending, uint(1))
s.DoJSON("GET", "/api/latest/fleet/scripts/batch?team_id=0&page=1&per_page=1", nil, http.StatusOK, &batchListResp)
require.Len(t, batchListResp.BatchScriptExecutions, 1)
require.Equal(t, batchListResp.Count, uint(2))
require.Equal(t, batchListResp.Meta.HasNextResults, false)
require.Equal(t, batchListResp.Meta.HasPreviousResults, true)
require.Equal(t, batchListResp.BatchScriptExecutions[0].BatchExecutionID, batchRes.BatchExecutionID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].ScriptID, script.ID)
require.Equal(t, *batchListResp.BatchScriptExecutions[0].NumTargeted, uint(2))
require.Equal(t, *batchListResp.BatchScriptExecutions[0].NumPending, uint(2))
var batchResUpcoming batchScriptRunResponse
// Queue scripts again, now in upcoming activities queue
// Scheduling something in the past makes it run right now
scheduledPast := time.Now().Add(-2 * time.Hour)
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID, host2.ID},
NotBefore: &scheduledPast,
}, http.StatusOK, &batchResUpcoming)
require.NotEmpty(t, batchResUpcoming.BatchExecutionID)
// save a result via the orbit endpoint
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host1.OrbitNodeKey, orbitRespHost1.Notifications.PendingScriptExecutionIDs[0])),
http.StatusOK, &orbitPostScriptResp)
// individual script executions don't get added to the global feed, only the batch job
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScriptBatch{}.ActivityName(),
fmt.Sprintf(`{"batch_execution_id":"%s", "host_count":2, "script_name":"%s", "team_id":null}`, batchResUpcoming.BatchExecutionID, script.Name),
0,
)
// batch script executions do get added to the host feed
var hostPastActivitiesResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host1.ID),
nil, http.StatusOK, &hostPastActivitiesResp)
expectedPastActivityDetail := fmt.Sprintf(`
{
"async": true,
"host_id": %d,
"policy_id": null,
"policy_name": null,
"batch_execution_id": "%s",
"script_execution_id": "%s",
"script_name": "%s",
"host_display_name": "%s"
}
`, host1.ID, batchRes.BatchExecutionID, orbitRespHost1.Notifications.PendingScriptExecutionIDs[0], script.Name, host1.DisplayName())
require.Len(t, hostPastActivitiesResp.Activities, 1)
require.NotNil(t, hostPastActivitiesResp.Activities[0].Details)
require.JSONEq(t, expectedPastActivityDetail, string(*hostPastActivitiesResp.Activities[0].Details))
// Queue upcoming execution
scheduledTime := time.Now().Add(10 * time.Hour)
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID, host2.ID},
NotBefore: &scheduledTime,
}, http.StatusOK, &batchRes)
require.NotEmpty(t, batchRes.BatchExecutionID)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeBatchScriptScheduled{}.ActivityName(),
fmt.Sprintf(`{"batch_execution_id":"%s", "host_count":2, "script_name":"%s", "team_id":null, "not_before": "%s"}`, batchRes.BatchExecutionID, script.Name, scheduledTime.UTC().Format(time.RFC3339Nano)),
0,
)
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID, host2.ID},
}, http.StatusOK, &batchResUpcoming)
require.NotEmpty(t, batchResUpcoming.BatchExecutionID)
// Queue again this time using a filter
filters := map[string]any{
"team_id": 0,
}
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
Filters: &filters,
NotBefore: &scheduledTime,
}, http.StatusOK, &batchRes)
require.NotEmpty(t, batchRes.BatchExecutionID)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeBatchScriptScheduled{}.ActivityName(),
fmt.Sprintf(`{"batch_execution_id":"%s", "host_count":2, "script_name":"%s", "team_id":null, "not_before": "%s"}`, batchRes.BatchExecutionID, script.Name, scheduledTime.UTC().Format(time.RFC3339Nano)),
0,
)
}
func (s *integrationEnterpriseTestSuite) TestCancelBatchScripts() {
t := s.T()
ctx := context.Background()
host1 := createOrbitEnrolledHost(t, "linux", "host1", s.ds)
host2 := createOrbitEnrolledHost(t, "linux", "host2", s.ds)
host3 := createOrbitEnrolledHost(t, "linux", "host3", s.ds)
host4 := createOrbitEnrolledHost(t, "linux", "host4", s.ds)
script, err := s.ds.NewScript(ctx, &fleet.Script{
Name: "script.sh",
ScriptContents: "echo bonjour",
})
require.NoError(t, err)
// Immediate execution
var batchRes batchScriptRunResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host1.ID, host2.ID},
}, http.StatusOK, &batchRes)
require.NotEmpty(t, batchRes.BatchExecutionID)
var batchStatusResp batchScriptExecutionStatusResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s", batchRes.BatchExecutionID), nil, http.StatusOK, &batchStatusResp)
require.Equal(t, *batchStatusResp.ScriptID, script.ID)
require.Equal(t, *batchStatusResp.NumTargeted, uint(2))
require.Equal(t, *batchStatusResp.NumPending, uint(2))
var batchCancelResp batchScriptCancelResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s/cancel", batchRes.BatchExecutionID), nil, http.StatusOK, &batchCancelResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeBatchScriptCanceled{}.ActivityName(),
fmt.Sprintf(`{"batch_execution_id": "%s", "host_count":2, "canceled_count": 2, "script_name":"%s"}`, batchRes.BatchExecutionID, script.Name),
0,
)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s", batchRes.BatchExecutionID), nil, http.StatusOK, &batchStatusResp)
require.Equal(t, *batchStatusResp.ScriptID, script.ID)
require.Equal(t, *batchStatusResp.NumTargeted, uint(2))
require.Equal(t, *batchStatusResp.NumPending, uint(0))
require.Equal(t, *batchStatusResp.NumCanceled, uint(2))
// Future execution
var batchResScheduled batchScriptRunResponse
scheduleTime := time.Now().Add(3 * time.Hour)
s.DoJSON("POST", "/api/latest/fleet/scripts/run/batch", batchScriptRunRequest{
ScriptID: script.ID,
HostIDs: []uint{host3.ID, host4.ID},
NotBefore: &scheduleTime,
}, http.StatusOK, &batchResScheduled)
require.NotEmpty(t, batchResScheduled.BatchExecutionID)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s", batchResScheduled.BatchExecutionID), nil, http.StatusOK, &batchStatusResp)
require.Equal(t, *batchStatusResp.ScriptID, script.ID)
require.Equal(t, *batchStatusResp.NumTargeted, uint(2))
require.Equal(t, *batchStatusResp.NumPending, uint(2))
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s/cancel", batchResScheduled.BatchExecutionID), nil, http.StatusOK, &batchCancelResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeBatchScriptCanceled{}.ActivityName(),
fmt.Sprintf(`{"batch_execution_id": "%s", "host_count":2, "canceled_count": 2, "script_name":"%s"}`, batchResScheduled.BatchExecutionID, script.Name),
0,
)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/batch/%s", batchResScheduled.BatchExecutionID), nil, http.StatusOK, &batchStatusResp)
require.Equal(t, *batchStatusResp.ScriptID, script.ID)
require.Equal(t, *batchStatusResp.NumTargeted, uint(2))
require.Equal(t, *batchStatusResp.NumPending, uint(0))
require.Equal(t, *batchStatusResp.NumCanceled, uint(2))
}
func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
t := s.T()
testRunScriptWaitForResult = 2 * time.Second
defer func() { testRunScriptWaitForResult = 0 }()
ctx := context.Background()
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
savedNoTmScript, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: nil,
Name: "no_team_script.sh",
ScriptContents: "echo 'no team'",
})
require.NoError(t, err)
savedTmScript, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: &tm.ID,
Name: "team_script.sh",
ScriptContents: "echo 'team'",
})
require.NoError(t, err)
// attempt to run a script on a non-existing host
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptID: &savedNoTmScript.ID}, http.StatusNotFound, &runResp)
// attempt to run with both script contents and id
res := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", ScriptID: ptr.Uint(savedTmScript.ID + 999)}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_id' or 'script_contents' is allowed.`)
// attempt to run with unknown script id
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: ptr.Uint(savedTmScript.ID + 999)}, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `No script exists for the provided "script_id".`)
// make sure the host is still seen as "online"
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
require.NoError(t, err)
// attempt to run a team script on a non-team host
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedTmScript.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `The script does not belong to the same team`)
// make sure the host is still seen as "online"
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
require.NoError(t, err)
// create a valid script execution request
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusAccepted, &runResp)
require.Equal(t, host.ID, runResp.HostID)
require.NotEmpty(t, runResp.ExecutionID)
var scriptResultResp getScriptResultResponse
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host.ID, scriptResultResp.HostID)
require.Equal(t, "echo 'no team'", scriptResultResp.ScriptContents)
require.Nil(t, scriptResultResp.ExitCode)
require.False(t, scriptResultResp.HostTimeout)
require.Contains(t, scriptResultResp.Message, fleet.RunScriptAsyncScriptEnqueuedMsg)
require.NotNil(t, scriptResultResp.ScriptID)
require.Equal(t, savedNoTmScript.ID, *scriptResultResp.ScriptID)
// verify that orbit would get the notification that it has a script to run
var orbitResp orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)),
http.StatusOK, &orbitResp)
require.Equal(t, []string{scriptResultResp.ExecutionID}, orbitResp.Notifications.PendingScriptExecutionIDs)
// the orbit endpoint to get a pending script to execute returns it
var orbitGetScriptResp orbitGetScriptResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/request",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)),
http.StatusOK, &orbitGetScriptResp)
require.Equal(t, host.ID, orbitGetScriptResp.HostID)
require.Equal(t, scriptResultResp.ExecutionID, orbitGetScriptResp.ExecutionID)
require.Equal(t, "echo 'no team'", orbitGetScriptResp.ScriptContents)
// make sure the host is still seen as "online"
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
require.NoError(t, err)
// save a result via the orbit endpoint
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// an activity was created for the script execution
s.lastActivityMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(
`{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null}`,
host.ID, host.DisplayName(), savedNoTmScript.Name, scriptResultResp.ExecutionID,
),
0,
)
// verify that orbit does not receive any pending script anymore
orbitResp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)),
http.StatusOK, &orbitResp)
require.Empty(t, orbitResp.Notifications.PendingScriptExecutionIDs)
// create a valid sync script execution request, fails because the
// request will time-out waiting for a result.
var runSyncResp runScriptSyncResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusRequestTimeout, &runSyncResp)
require.Equal(t, host.ID, runSyncResp.HostID)
require.NotEmpty(t, runSyncResp.ExecutionID)
require.NotNil(t, runSyncResp.ScriptID)
require.Equal(t, savedNoTmScript.ID, *runSyncResp.ScriptID)
require.Equal(t, "echo 'no team'", runSyncResp.ScriptContents)
require.True(t, runSyncResp.HostTimeout)
require.Contains(t, runSyncResp.Message, fleet.RunScriptHostTimeoutErrMsg)
// attempt to run sync with both script contents and script id
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", ScriptID: ptr.Uint(savedTmScript.ID + 999)}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_id' or 'script_contents' is allowed.`)
// attempt to run sync with both script contents and script name
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", ScriptName: savedTmScript.Name}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_contents' or 'script_name' is allowed.`)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", ScriptName: savedTmScript.Name}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_contents' or 'script_name' is allowed.`)
// attempt to run sync with both script id and script name
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: ptr.Uint(savedTmScript.ID + 999), ScriptName: savedTmScript.Name}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_id' or 'script_name' is allowed.`)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: ptr.Uint(savedTmScript.ID + 999), ScriptName: savedTmScript.Name}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_id' or 'script_name' is allowed.`)
// attempt to run sync with both script contents and team id
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", TeamID: 1}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_contents' or 'team_id' is allowed.`)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", TeamID: 1}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_contents' or 'team_id' is allowed.`)
// attempt to run sync with both script id and team id
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: ptr.Uint(savedTmScript.ID + 999), TeamID: 1}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_id' or 'team_id' is allowed.`)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: ptr.Uint(savedTmScript.ID + 999), TeamID: 1}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Only one of 'script_id' or 'team_id' is allowed.`)
// attempt to run sync without script contents, script id, or script name
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `One of 'script_id', 'script_contents', or 'script_name' is required.`)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `One of 'script_id', 'script_contents', or 'script_name' is required.`)
// deleting the saved script should delete the pending script
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", savedNoTmScript.ID), nil, http.StatusNoContent)
scriptResultResp = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runSyncResp.ExecutionID, nil, http.StatusNotFound, &scriptResultResp)
// Verify that we can't enqueue more than 1k scripts
// Make the host offline so that scripts enqueue
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour))
require.NoError(t, err)
for i := 1; i <= 1000; i++ {
script, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: nil,
Name: fmt.Sprintf("script_1k_%d.sh", i),
ScriptContents: fmt.Sprintf("echo %d", i),
})
require.NoError(t, err)
_, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID})
require.NoError(t, err)
}
script, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: nil,
Name: "script_1k_1001.sh",
ScriptContents: "echo 1001",
})
require.NoError(t, err)
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusConflict, &runResp)
// set up a new host, new team, and some new scripts
host2 := createOrbitEnrolledHost(t, "linux", "f1337", s.ds)
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
savedNoTmScript2, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: nil,
Name: "f1337.sh",
ScriptContents: "echo 'ALL YOUR BASE ARE BELONG TO US'",
})
require.NoError(t, err)
savedTmScript2, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: &tm2.ID,
Name: "f1337.sh",
ScriptContents: "echo 'ALL YOUR BASE ARE BELONG TO US'",
})
require.NoError(t, err)
require.NotEqual(t, savedNoTmScript2.ID, savedTmScript2.ID)
_, err = s.ds.NewScript(ctx, &fleet.Script{
TeamID: nil,
Name: "f13372.sh",
ScriptContents: "echo 'ALL YOUR BASE ARE BELONG TO US'",
})
require.NoError(t, err)
// make sure the new host is seen as "online"
err = s.ds.MarkHostsSeen(ctx, []uint{host2.ID}, time.Now())
require.NoError(t, err)
// attempt to run sync with a script that does not exist on the specified team
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host2.ID, ScriptName: "f1337.sh", TeamID: tm.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Script 'f1337.sh' doesn’t exist.`)
// attempt to run sync with an existing team script that belongs to a team different from the host's team
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host2.ID, ScriptName: "f1337.sh", TeamID: tm2.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `The script does not belong to the same team`)
// create a valid sync script execution request by script name, fails because the
// request will time-out waiting for a result.
var runSyncResp2 runScriptSyncResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host2.ID, ScriptName: "f1337.sh"}, http.StatusRequestTimeout, &runSyncResp2)
require.Equal(t, host2.ID, runSyncResp2.HostID)
require.NotEmpty(t, runSyncResp2.ExecutionID)
require.NotNil(t, runSyncResp2.ScriptID)
require.Equal(t, savedNoTmScript2.ID, *runSyncResp2.ScriptID)
require.Equal(t, "echo 'ALL YOUR BASE ARE BELONG TO US'", runSyncResp2.ScriptContents)
require.True(t, runSyncResp2.HostTimeout)
require.Contains(t, runSyncResp2.Message, fleet.RunScriptHostTimeoutErrMsg)
// attempt to run a script on a plain osquery host
plainOsqueryHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Minute),
OsqueryHostID: ptr.String("plain-osquery-host-2"),
NodeKey: ptr.String("plain-osquery-host-2"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.local", "plain-osquery-host-2"),
HardwareSerial: uuid.New().String(),
Platform: "linux",
})
require.NoError(t, err)
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: plainOsqueryHost.ID, ScriptID: &script.ID}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), fleet.RunScriptDisabledErrMsg)
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: plainOsqueryHost.ID, ScriptID: &script.ID}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), fleet.RunScriptDisabledErrMsg)
// Async Run Script by Name
// attempt to run async with a script that does not exist on the specified team
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host2.ID, ScriptName: "f1337.sh", TeamID: tm.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Script 'f1337.sh' doesn’t exist.`)
// attempt to run async with an existing team script that belongs to a team different from the host's team
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host2.ID, ScriptName: "f1337.sh", TeamID: tm2.ID}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `The script does not belong to the same team`)
var runSyncResp3 runScriptSyncResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host2.ID, ScriptName: "f13372.sh"}, http.StatusAccepted, &runSyncResp3)
require.Equal(t, host2.ID, runSyncResp3.HostID)
require.NotEmpty(t, runSyncResp3.ExecutionID)
// verify pending result
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runSyncResp3.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host2.ID, scriptResultResp.HostID)
require.Equal(t, "echo 'ALL YOUR BASE ARE BELONG TO US'", scriptResultResp.ScriptContents)
require.Nil(t, scriptResultResp.ExitCode)
require.False(t, scriptResultResp.HostTimeout)
require.Contains(t, scriptResultResp.Message, fleet.RunScriptAsyncScriptEnqueuedMsg)
}
func (s *integrationEnterpriseTestSuite) TestEnqueueSameScriptTwice() {
t := s.T()
ctx := context.Background()
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
script, err := s.ds.NewScript(ctx, &fleet.Script{
TeamID: nil,
Name: "script.sh",
ScriptContents: "echo 'hi from script'",
})
require.NoError(t, err)
// Make the host offline so that scripts enqueue
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour))
require.NoError(t, err)
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusAccepted, &runResp)
require.Equal(t, host.ID, runResp.HostID)
require.NotEmpty(t, runResp.ExecutionID)
// Should fail because the same script is already enqueued for this host.
resp := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusConflict)
errorMsg := extractServerErrorText(resp.Body)
require.Contains(t, errorMsg, "The script is already queued on the given host")
}
func (s *integrationEnterpriseTestSuite) TestOrbitConfigExtensions() {
t := s.T()
ctx := context.Background()
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
defer func() {
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
}()
foobarLabel, err := s.ds.NewLabel(ctx, &fleet.Label{
Name: "Foobar",
Query: "SELECT 1;",
})
require.NoError(t, err)
zoobarLabel, err := s.ds.NewLabel(ctx, &fleet.Label{
Name: "Zoobar",
Query: "SELECT 1;",
})
require.NoError(t, err)
allHostsLabel, err := s.ds.GetLabelSpec(ctx, "All hosts")
require.NoError(t, err)
orbitDarwinClient := createOrbitEnrolledHost(t, "darwin", "foobar1", s.ds)
orbitLinuxClient := createOrbitEnrolledHost(t, "linux", "foobar2", s.ds)
orbitWindowsClient := createOrbitEnrolledHost(t, "windows", "foobar3", s.ds)
// orbitDarwinClient is member of 'All hosts' and 'Zoobar' labels.
err = s.ds.RecordLabelQueryExecutions(ctx, orbitDarwinClient, map[uint]*bool{
allHostsLabel.ID: ptr.Bool(true),
zoobarLabel.ID: ptr.Bool(true),
}, time.Now(), false)
require.NoError(t, err)
// orbitLinuxClient is member of 'All hosts' and 'Foobar' labels.
err = s.ds.RecordLabelQueryExecutions(ctx, orbitLinuxClient, map[uint]*bool{
allHostsLabel.ID: ptr.Bool(true),
foobarLabel.ID: ptr.Bool(true),
}, time.Now(), false)
require.NoError(t, err)
// orbitWindowsClient is member of the 'All hosts' label only.
err = s.ds.RecordLabelQueryExecutions(ctx, orbitWindowsClient, map[uint]*bool{
allHostsLabel.ID: ptr.Bool(true),
}, time.Now(), false)
require.NoError(t, err)
// Attempt to add labels to extensions.
s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{
"agent_options": {
"config": {
"options": {
"pack_delimiter": "/",
"logger_tls_period": 10,
"distributed_plugin": "tls",
"disable_distributed": false,
"logger_tls_endpoint": "/api/osquery/log",
"distributed_interval": 10,
"distributed_tls_max_attempts": 3
}
},
"extensions": {
"hello_world_linux": {
"labels": [
"All hosts",
"Foobar"
],
"channel": "stable",
"platform": "linux"
},
"hello_world_macos": {
"labels": [
"All hosts",
"Foobar"
],
"channel": "stable",
"platform": "macos"
},
"hello_mars_macos": {
"labels": [
"All hosts",
"Zoobar"
],
"channel": "stable",
"platform": "macos"
},
"hello_world_windows": {
"labels": [
"Zoobar"
],
"channel": "stable",
"platform": "windows"
},
"hello_mars_windows": {
"labels": [
"Foobar"
],
"channel": "stable",
"platform": "windows"
}
}
}
}`), http.StatusOK)
resp := orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *orbitDarwinClient.OrbitNodeKey)), http.StatusOK, &resp)
require.JSONEq(t, `{
"hello_mars_macos": {
"channel": "stable",
"platform": "macos"
}
}`, string(resp.Extensions))
resp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *orbitLinuxClient.OrbitNodeKey)), http.StatusOK, &resp)
require.JSONEq(t, `{
"hello_world_linux": {
"channel": "stable",
"platform": "linux"
}
}`, string(resp.Extensions))
resp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *orbitWindowsClient.OrbitNodeKey)), http.StatusOK, &resp)
require.Empty(t, string(resp.Extensions))
// orbitDarwinClient is now also a member of the 'Foobar' label.
err = s.ds.RecordLabelQueryExecutions(ctx, orbitDarwinClient, map[uint]*bool{
foobarLabel.ID: ptr.Bool(true),
}, time.Now(), false)
require.NoError(t, err)
resp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *orbitDarwinClient.OrbitNodeKey)), http.StatusOK, &resp)
require.JSONEq(t, `{
"hello_world_macos": {
"channel": "stable",
"platform": "macos"
},
"hello_mars_macos": {
"channel": "stable",
"platform": "macos"
}
}`, string(resp.Extensions))
// orbitLinuxClient is no longer a member of the 'Foobar' label.
err = s.ds.RecordLabelQueryExecutions(ctx, orbitLinuxClient, map[uint]*bool{
foobarLabel.ID: nil,
}, time.Now(), false)
require.NoError(t, err)
resp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *orbitLinuxClient.OrbitNodeKey)), http.StatusOK, &resp)
require.Empty(t, string(resp.Extensions))
// Attempt to set non-existent labels in the config.
s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{
"agent_options": {
"config": {
"options": {
"pack_delimiter": "/",
"logger_tls_period": 10,
"distributed_plugin": "tls",
"disable_distributed": false,
"logger_tls_endpoint": "/api/osquery/log",
"distributed_interval": 10,
"distributed_tls_max_attempts": 3
}
},
"extensions": {
"hello_world_linux": {
"labels": [
"All hosts",
"Doesn't exist"
],
"channel": "stable",
"platform": "linux"
}
}
}
}`), http.StatusBadRequest)
}
func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
t := s.T()
ctx := context.Background()
// create a saved script for no team
var newScriptResp createScriptResponse
body, headers := generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, nil)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err := json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err)
require.NotZero(t, newScriptResp.ScriptID)
noTeamScriptID := newScriptResp.ScriptID
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": null, "team_id": null}`, "script1.sh"), 0)
// get the script
var getScriptResp getScriptResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusOK, &getScriptResp)
require.Equal(t, noTeamScriptID, getScriptResp.ID)
require.Nil(t, getScriptResp.TeamID)
require.Equal(t, "script1.sh", getScriptResp.Name)
require.NotZero(t, getScriptResp.CreatedAt)
require.NotZero(t, getScriptResp.UpdatedAt)
require.Empty(t, getScriptResp.ScriptContents)
// download the script's content
res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusOK, "alt", "media")
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, `echo "hello"`, string(b))
require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength)
require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition"))
// get a non-existing script
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID+999), nil, http.StatusNotFound, &getScriptResp)
// download a non-existing script
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID+999), nil, http.StatusNotFound, &getScriptResp, "alt", "media")
// file name is empty
body, headers = generateNewScriptMultipartRequest(t,
"", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusBadRequest, headers)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "no file headers for script")
// contains invalid fleet secret
body, headers = generateNewScriptMultipartRequest(t,
"secrets.sh", []byte(`echo "$FLEET_SECRET_INVALID"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
// file name is not .sh
body, headers = generateNewScriptMultipartRequest(t,
"not_sh.txt", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh and .ps1 file type is allowed.")
// file content is empty
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(``), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script contents must not be empty")
// file content is too large
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(strings.Repeat("a", fleet.SavedScriptMaxRuneLen+1)), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script is too large. It's limited to 500,000 characters")
// invalid hashbang
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(`#!/bin/python`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Interpreter not supported.")
// script already exists with this name for this no-team
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A script with this name already exists")
// team id does not exist
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {"123"}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusNotFound, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "The team does not exist.")
// create a team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
// create with existing name for this time for a team
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "team"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err)
require.NotZero(t, newScriptResp.ScriptID)
require.NotEqual(t, noTeamScriptID, newScriptResp.ScriptID)
tmScriptID := newScriptResp.ScriptID
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0)
// create a windows script
body, headers = generateNewScriptMultipartRequest(t,
"script2.ps1", []byte(`Write-Host "Hello, World!"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err)
require.NotZero(t, newScriptResp.ScriptID)
require.NotEqual(t, noTeamScriptID, newScriptResp.ScriptID)
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script2.ps1", tm.Name, tm.ID), 0)
// get team's script
getScriptResp = getScriptResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusOK, &getScriptResp)
require.Equal(t, tmScriptID, getScriptResp.ID)
require.NotNil(t, getScriptResp.TeamID)
require.Equal(t, tm.ID, *getScriptResp.TeamID)
require.Equal(t, "script1.sh", getScriptResp.Name)
require.NotZero(t, getScriptResp.CreatedAt)
require.NotZero(t, getScriptResp.UpdatedAt)
require.Empty(t, getScriptResp.ScriptContents)
// download the team's script's content
res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusOK, "alt", "media")
b, err = io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, `echo "team"`, string(b))
require.Equal(t, int64(len(`echo "team"`)), res.ContentLength)
require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition"))
// script already exists with this name for this team
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A script with this name already exists")
// create with a different name for this team
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err)
require.NotZero(t, newScriptResp.ScriptID)
require.NotEqual(t, noTeamScriptID, newScriptResp.ScriptID)
require.NotEqual(t, tmScriptID, newScriptResp.ScriptID)
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script2.sh", tm.Name, tm.ID), 0)
// Update a script
updateScriptRep := updateScriptResponse{}
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "updated script"`), s.token, nil)
res = s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&updateScriptRep)
require.NoError(t, err)
require.NotZero(t, newScriptResp.ScriptID)
require.Equal(t, tmScriptID, updateScriptRep.ScriptID)
s.lastActivityMatches("updated_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0)
// Download the updated script
res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusOK, "alt", "media")
b, err = io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, `echo "updated script"`, string(b))
require.Equal(t, int64(len(`echo "updated script"`)), res.ContentLength)
require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition"))
// Try updating a non-existant script
updateScriptRep = updateScriptResponse{}
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "updated script"`), s.token, nil)
res = s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/scripts/%d", 999999999999), body.Bytes(), http.StatusNotFound, headers)
err = json.NewDecoder(res.Body).Decode(&updateScriptRep)
require.NoError(t, err)
// delete the no-team script
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusNoContent)
s.lastActivityMatches("deleted_script", fmt.Sprintf(`{"script_name": %q, "team_name": null, "team_id": null}`, "script1.sh"), 0)
// delete the initial team script
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusNoContent)
s.lastActivityMatches("deleted_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0)
// delete a non-existing script
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusNotFound)
}
func (s *integrationEnterpriseTestSuite) TestListSavedScripts() {
t := s.T()
ctx := context.Background()
// create some teams
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
require.NoError(t, err)
// create 5 scripts for no team and team 1
for i := 0; i < 5; i++ {
_, err = s.ds.NewScript(ctx, &fleet.Script{
Name: string('a' + byte(i)), // i.e. "a", "b", "c", ...
ScriptContents: "echo",
})
require.NoError(t, err)
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: string('a' + byte(i)), TeamID: &tm1.ID, ScriptContents: "echo"})
require.NoError(t, err)
}
// create a single script for team 2
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: "a", TeamID: &tm2.ID, ScriptContents: "echo"})
require.NoError(t, err)
cases := []struct {
queries []string // alternate query name and value
teamID *uint
wantNames []string
wantMeta *fleet.PaginationMetadata
}{
{
wantNames: []string{"a", "b", "c", "d", "e"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2"},
wantNames: []string{"a", "b"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2", "page", "1"},
wantNames: []string{"c", "d"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
},
{
queries: []string{"per_page", "2", "page", "2"},
wantNames: []string{"e"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3"},
teamID: &tm1.ID,
wantNames: []string{"a", "b", "c"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
},
{
queries: []string{"per_page", "3", "page", "1"},
teamID: &tm1.ID,
wantNames: []string{"d", "e"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3", "page", "2"},
teamID: &tm1.ID,
wantNames: nil,
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3"},
teamID: &tm2.ID,
wantNames: []string{"a"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2"},
teamID: &tm3.ID,
wantNames: nil,
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) {
var listResp listScriptsResponse
queryArgs := c.queries
if c.teamID != nil {
queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID))
}
s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusOK, &listResp, queryArgs...)
require.Equal(t, len(c.wantNames), len(listResp.Scripts))
require.Equal(t, c.wantMeta, listResp.Meta)
var gotNames []string
if len(listResp.Scripts) > 0 {
gotNames = make([]string, len(listResp.Scripts))
for i, s := range listResp.Scripts {
gotNames[i] = s.Name
if c.teamID == nil {
require.Nil(t, s.TeamID)
} else {
require.NotNil(t, s.TeamID)
require.Equal(t, *c.teamID, *s.TeamID)
}
}
}
require.Equal(t, c.wantNames, gotNames)
})
}
}
func (s *integrationEnterpriseTestSuite) TestHostScriptDetails() {
t := s.T()
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
// create some teams
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team1"})
require.NoError(t, err)
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team2"})
require.NoError(t, err)
tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team3"})
require.NoError(t, err)
tm4, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team4-windows"})
require.NoError(t, err)
// create 5 scripts for no team and team 1
for i := 0; i < 5; i++ {
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d.sh", i), ScriptContents: "echo"})
require.NoError(t, err)
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d.sh", i), TeamID: &tm1.ID, ScriptContents: "echo"})
require.NoError(t, err)
}
// add a windows script to team 4
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: "test-script-details-windows.ps1", TeamID: &tm4.ID, ScriptContents: `Write-Host "Hello, World!"`})
require.NoError(t, err)
// create a single script for team 2
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: "test-script-details-team-2.sh", TeamID: &tm2.ID, ScriptContents: "echo"})
require.NoError(t, err)
// create a host without a team
host0, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String("host0"),
NodeKey: ptr.String("host0"),
UUID: uuid.New().String(),
Hostname: "host0",
Platform: "darwin",
})
require.NoError(t, err)
// create a host for team 1
host1, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String("host1"),
NodeKey: ptr.String("host1"),
UUID: uuid.New().String(),
Hostname: "host1",
Platform: "darwin",
TeamID: &tm1.ID,
})
require.NoError(t, err)
// create a host for team 3
host2, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String("host2"),
NodeKey: ptr.String("host2"),
UUID: uuid.New().String(),
Hostname: "host2",
Platform: "darwin",
TeamID: &tm3.ID,
})
require.NoError(t, err)
// create a Windows host
host3, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String("host3"),
NodeKey: ptr.String("host3"),
UUID: uuid.New().String(),
Hostname: "host3",
Platform: "windows",
TeamID: &tm4.ID,
})
require.NoError(t, err)
// create a Linux host
host4, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String("host4"),
NodeKey: ptr.String("host4"),
UUID: uuid.New().String(),
Hostname: "host4",
Platform: "ubuntu",
TeamID: nil,
})
require.NoError(t, err)
// create a chrome host
host5, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String("host5"),
NodeKey: ptr.String("host5"),
UUID: uuid.New().String(),
Hostname: "host5",
Platform: "chrome",
TeamID: nil,
})
require.NoError(t, err)
// it's fine to write directly to host_script_results here because we're not testing the execution
// only the script details
insertResults := func(t *testing.T, hostID uint, script *fleet.Script, createdAt time.Time, execID string, exitCode *int64) {
stmt := `
INSERT INTO
host_script_results (%s host_id, created_at, execution_id, exit_code, script_content_id, output, sync_request)
VALUES
(%s ?,?,?,?,?,?, 1)`
args := []interface{}{}
var scID uint
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
// First check if this script content already exists
row := tx.QueryRowxContext(ctx, `
SELECT id FROM script_contents WHERE md5_checksum = UNHEX(MD5(?))
`, script.ScriptContents)
err := row.Scan(&scID)
if errors.Is(err, sql.ErrNoRows) {
// Content doesn't exist, insert it
res, err := tx.ExecContext(ctx, `
INSERT INTO
script_contents (md5_checksum, contents, created_at)
VALUES
(UNHEX(MD5(?)),?,?)`,
script.ScriptContents,
script.ScriptContents,
createdAt,
)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
scID = uint(id) //nolint:gosec // dismiss G115
} else if err != nil {
return err
}
return nil
})
if script.ID == 0 {
stmt = fmt.Sprintf(stmt, "", "")
} else {
stmt = fmt.Sprintf(stmt, "script_id,", "?,")
args = append(args, script.ID)
}
args = append(args, hostID, createdAt, execID, exitCode, scID, "")
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, stmt, args...)
return err
})
}
// insert some ad hoc script results, these are never included in the host script details
insertResults(t, host0.ID, &fleet.Script{Name: "ad hoc script", ScriptContents: "echo foo"}, now, "ad-hoc-0", ptr.Int64(0))
insertResults(t, host1.ID, &fleet.Script{Name: "ad hoc script", ScriptContents: "echo foo"}, now.Add(-1*time.Hour), "ad-hoc-1", ptr.Int64(1))
t.Run("no team", func(t *testing.T) {
noTeamScripts, _, err := s.ds.ListScripts(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, noTeamScripts, 5)
// insert saved script results for host0
insertResults(t, host0.ID, noTeamScripts[0], now, "exec0-0", ptr.Int64(0)) // expect status ran
insertResults(t, host0.ID, noTeamScripts[1], now.Add(-1*time.Hour), "exec0-1", ptr.Int64(1)) // expect status error
insertResults(t, host0.ID, noTeamScripts[2], now.Add(-2*time.Hour), "exec0-2", nil) // expect status pending
// insert some ad hoc script results, these are never included in the host script details
insertResults(t, host0.ID, &fleet.Script{Name: "ad hoc script", ScriptContents: "echo foo"}, now.Add(-3*time.Hour), "exec0-3", ptr.Int64(0))
// check host script details, should include all no team scripts
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host0.ID), nil, http.StatusOK, &resp)
require.Len(t, resp.Scripts, len(noTeamScripts))
byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts))
for _, s := range resp.Scripts {
byScriptID[s.ScriptID] = s
}
for i, s := range noTeamScripts {
gotScript, ok := byScriptID[s.ID]
require.True(t, ok)
require.Equal(t, s.Name, gotScript.Name)
switch i {
case 0:
require.NotNil(t, gotScript.LastExecution)
require.Equal(t, "exec0-0", gotScript.LastExecution.ExecutionID)
require.Equal(t, now, gotScript.LastExecution.ExecutedAt)
require.Equal(t, "ran", gotScript.LastExecution.Status)
case 1:
require.NotNil(t, gotScript.LastExecution)
require.Equal(t, "exec0-1", gotScript.LastExecution.ExecutionID)
require.Equal(t, now.Add(-1*time.Hour), gotScript.LastExecution.ExecutedAt)
require.Equal(t, "error", gotScript.LastExecution.Status)
case 2:
require.NotNil(t, gotScript.LastExecution)
require.Equal(t, "exec0-2", gotScript.LastExecution.ExecutionID)
require.Equal(t, now.Add(-2*time.Hour), gotScript.LastExecution.ExecutedAt)
require.Equal(t, "pending", gotScript.LastExecution.Status)
default:
require.Nil(t, gotScript.LastExecution)
}
}
})
t.Run("team 1", func(t *testing.T) {
tm1Scripts, _, err := s.ds.ListScripts(ctx, &tm1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, tm1Scripts, 5)
// insert results for host1
insertResults(t, host1.ID, tm1Scripts[0], now, "exec1-0", ptr.Int64(0)) // expect status ran
// check host script details, should match team 1
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host1.ID), nil, http.StatusOK, &resp)
require.Len(t, resp.Scripts, len(tm1Scripts))
byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts))
for _, s := range resp.Scripts {
byScriptID[s.ScriptID] = s
}
for i, s := range tm1Scripts {
gotScript, ok := byScriptID[s.ID]
require.True(t, ok)
require.Equal(t, s.Name, gotScript.Name)
switch i {
case 0:
require.NotNil(t, gotScript.LastExecution)
require.Equal(t, "exec1-0", gotScript.LastExecution.ExecutionID)
require.Equal(t, now, gotScript.LastExecution.ExecutedAt)
require.Equal(t, "ran", gotScript.LastExecution.Status)
default:
require.Nil(t, gotScript.LastExecution)
}
}
})
t.Run("deleted script", func(t *testing.T) {
noTeamScripts, _, err := s.ds.ListScripts(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, noTeamScripts, 5)
// delete a script
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScripts[0].ID), nil, http.StatusNoContent)
// check host script details, should not include deleted script
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host0.ID), nil, http.StatusOK, &resp)
require.Len(t, resp.Scripts, len(noTeamScripts)-1)
byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts))
for _, s := range resp.Scripts {
require.NotEqual(t, noTeamScripts[0].ID, s.ScriptID)
byScriptID[s.ScriptID] = s
}
for i, s := range noTeamScripts {
gotScript, ok := byScriptID[s.ID]
if i == 0 {
require.False(t, ok)
} else {
require.True(t, ok)
require.Equal(t, s.Name, gotScript.Name)
switch i {
case 1:
require.NotNil(t, gotScript.LastExecution)
require.Equal(t, "exec0-1", gotScript.LastExecution.ExecutionID)
require.Equal(t, now.Add(-1*time.Hour), gotScript.LastExecution.ExecutedAt)
require.Equal(t, "error", gotScript.LastExecution.Status)
case 2:
require.NotNil(t, gotScript.LastExecution)
require.Equal(t, "exec0-2", gotScript.LastExecution.ExecutionID)
require.Equal(t, now.Add(-2*time.Hour), gotScript.LastExecution.ExecutedAt)
require.Equal(t, "pending", gotScript.LastExecution.Status)
case 3, 4:
require.Nil(t, gotScript.LastExecution)
default:
require.Fail(t, "unexpected script")
}
}
}
})
t.Run("transfer team", func(t *testing.T) {
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &tm2.ID,
HostIDs: []uint{host1.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
tm2Scripts, _, err := s.ds.ListScripts(ctx, &tm2.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, tm2Scripts, 1)
// check host script details, should not include prior team's scripts
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host1.ID), nil, http.StatusOK, &resp)
require.Len(t, resp.Scripts, len(tm2Scripts))
byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts))
for _, s := range resp.Scripts {
byScriptID[s.ScriptID] = s
}
for _, s := range tm2Scripts {
gotScript, ok := byScriptID[s.ID]
require.True(t, ok)
require.Equal(t, s.Name, gotScript.Name)
require.Nil(t, gotScript.LastExecution)
}
})
t.Run("no scripts", func(t *testing.T) {
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host2.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.Scripts)
require.Len(t, resp.Scripts, 0)
})
t.Run("windows", func(t *testing.T) {
team4Scripts, _, err := s.ds.ListScripts(ctx, &tm4.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, team4Scripts, 1)
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host3.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.Scripts)
require.Len(t, resp.Scripts, 1)
})
t.Run("linux", func(t *testing.T) {
require.Nil(t, host4.TeamID)
noTeamScripts, _, err := s.ds.ListScripts(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.True(t, len(noTeamScripts) > 0)
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host4.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.Scripts)
require.Len(t, resp.Scripts, 4)
for _, s := range resp.Scripts {
require.Nil(t, s.LastExecution)
require.Contains(t, s.Name, ".sh")
}
})
// NOTE: Scripts are specified only for platforms other than macOS, Linux,
// and Windows; however, we default to listing all scripts for unspecified platforms.
// Separately, the UI restricts scripts related functionality to only macOS,
// Linux, and Windows.
t.Run("unspecified platform", func(t *testing.T) {
var resp getHostScriptDetailsResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host5.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.Scripts)
require.Len(t, resp.Scripts, 4)
})
t.Run("get script results user message", func(t *testing.T) {
// add a script with an older created_at timestamp
var oldScriptID, oldScriptContentsID uint
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
// create script_contents first
res, err := tx.ExecContext(ctx, `
INSERT INTO
script_contents (md5_checksum, contents, created_at)
VALUES
(UNHEX(MD5(?)),?,?)`,
"echo test-script-details-timeout",
"echo test-script-details-timeout",
now.Add(-1*time.Hour),
)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
oldScriptContentsID = uint(id) //nolint:gosec // dismiss G115
res, err = tx.ExecContext(ctx, `
INSERT INTO
scripts (name, script_content_id, created_at, updated_at)
VALUES
(?,?,?,?)`,
"test-script-details-timeout.sh",
oldScriptContentsID,
now.Add(-1*time.Hour),
now.Add(-1*time.Hour),
)
if err != nil {
return err
}
id, err = res.LastInsertId()
if err != nil {
return err
}
oldScriptID = uint(id) //nolint:gosec // dismiss G115
return nil
})
for _, c := range []struct {
name string
exitCode *int64
executedAt time.Time
expected string
}{
{
name: "host-timeout",
exitCode: nil,
executedAt: now.Add(-1 * time.Hour),
expected: fleet.RunScriptHostTimeoutErrMsg,
},
{
name: "script-timeout",
exitCode: ptr.Int64(-1),
executedAt: now.Add(-1 * time.Hour),
expected: fleet.HostScriptTimeoutMessage(ptr.Int(int(scripts.MaxHostExecutionTime.Seconds()))),
},
{
name: "pending",
exitCode: nil,
executedAt: now.Add(-1 * time.Minute),
expected: fleet.RunScriptAlreadyRunningErrMsg,
},
{
name: "success",
exitCode: ptr.Int64(0),
executedAt: now.Add(-1 * time.Hour),
expected: "",
},
{
name: "error",
exitCode: ptr.Int64(1),
executedAt: now.Add(-1 * time.Hour),
expected: "",
},
{
name: "disabled",
exitCode: ptr.Int64(-2),
executedAt: now.Add(-1 * time.Hour),
expected: fleet.RunScriptDisabledErrMsg,
},
} {
t.Run(c.name, func(t *testing.T) {
insertResults(t, host0.ID, &fleet.Script{ID: oldScriptID, ScriptContentID: oldScriptContentsID, Name: "test-script-details-timeout.sh"}, c.executedAt, "test-user-message_"+c.name, c.exitCode)
var resp getScriptResultResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/results/%s", "test-user-message_"+c.name), nil, http.StatusOK, &resp)
require.Equal(t, c.expected, resp.Message)
})
}
})
}
// generates the body and headers part of a multipart request ready to be
// used via s.DoRawWithHeaders to POST /api/_version_/fleet/scripts.
func generateNewScriptMultipartRequest(t *testing.T,
fileName string, fileContent []byte, token string, extraFields map[string][]string,
) (*bytes.Buffer, map[string]string) {
return generateMultipartRequest(t, "script", fileName, fileContent, token, extraFields)
}
func (s *integrationEnterpriseTestSuite) TestAppConfigScripts() {
t := s.T()
// set the script fields
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": ["foo", "bar"] }`), http.StatusOK, &acResp)
assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value)
// check that they are returned by a GET /config
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value)
// patch without specifying the scripts fields, should not remove them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{}`), http.StatusOK, &acResp)
assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value)
// patch with explicitly empty scripts fields, would remove
// them but this is a dry-run
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": null }`), http.StatusOK, &acResp, "dry_run", "true")
assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value)
// patch with explicitly empty scripts fields, removes them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": null }`), http.StatusOK, &acResp)
assert.Empty(t, acResp.Scripts.Value)
// set the script fields again
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": ["foo", "bar"] }`), http.StatusOK, &acResp)
assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value)
// patch with an empty array sets the scripts to an empty array as well
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": [] }`), http.StatusOK, &acResp)
assert.Empty(t, acResp.Scripts.Value)
// patch with an invalid array returns an error
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": ["foo", 1] }`), http.StatusBadRequest, &acResp)
assert.Empty(t, acResp.Scripts.Value)
}
func (s *integrationEnterpriseTestSuite) TestApplyTeamsScriptsConfig() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
// apply with scripts
// must not use applyTeamSpecsRequest and marshal it as JSON, as it will set
// all keys to their zerovalue, and some are only valid with mdm enabled.
teamSpecs := map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"scripts": []string{"foo", "bar"},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// retrieving the team returns the scripts
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []string{"foo", "bar"}, teamResp.Team.Config.Scripts.Value)
// apply without custom scripts specified, should not replace existing scripts
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []string{"foo", "bar"}, teamResp.Team.Config.Scripts.Value)
// apply with explicitly empty custom scripts would clear the existing
// scripts, but dry-run
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"scripts": nil,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []string{"foo", "bar"}, teamResp.Team.Config.Scripts.Value)
// apply with explicitly empty scripts clears the existing scripts
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"scripts": nil,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.Scripts.Value)
// patch with an invalid array returns an error
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"scripts": []any{"foo", 1},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.Scripts.Value)
}
func (s *integrationEnterpriseTestSuite) TestBatchApplyScriptsEndpoints() {
t := s.T()
ctx := context.Background()
saveAndCheckScripts := func(team *fleet.Team, scripts []fleet.ScriptPayload) {
var teamID *uint
teamIDStr := ""
teamActivity := `{"team_id": null, "team_name": null}`
if team != nil {
teamID = &team.ID
teamIDStr = fmt.Sprint(team.ID)
teamActivity = fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, team.Name)
}
// create, check activities, and check scripts response
var scriptsBatchResponse batchSetScriptsResponse
s.DoJSON("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: scripts}, http.StatusOK, &scriptsBatchResponse, "team_id", teamIDStr)
s.lastActivityMatches(
fleet.ActivityTypeEditedScript{}.ActivityName(),
teamActivity,
0,
)
require.Len(t, scriptsBatchResponse.Scripts, len(scripts))
// check that the right values got stored in the db
var listResp listScriptsResponse
s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusOK, &listResp, "team_id", teamIDStr)
require.Len(t, listResp.Scripts, len(scripts))
got := make([]fleet.ScriptPayload, len(scripts))
for i, gotScript := range listResp.Scripts {
// add the script contents
res := s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", gotScript.ID), nil, http.StatusOK, "alt", "media")
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
got[i] = fleet.ScriptPayload{
Name: gotScript.Name,
ScriptContents: b,
}
// check that it belongs to the right team
require.Equal(t, teamID, gotScript.TeamID)
}
require.ElementsMatch(t, scripts, got)
}
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_scripts"})
require.NoError(t, err)
// apply an empty set to no-team
saveAndCheckScripts(nil, nil)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate script names
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{
{Name: "N1.sh", ScriptContents: []byte("foo")},
{Name: "N1.sh", ScriptContents: []byte("bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// invalid script name
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{
{Name: "N1", ScriptContents: []byte("foo")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// empty script name
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{
{Name: "", ScriptContents: []byte("foo")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// invalid secret
s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{
{Name: "N2.sh", ScriptContents: []byte("echo $FLEET_SECRET_INVALID")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// successfully apply a scripts for the team
saveAndCheckScripts(tm, []fleet.ScriptPayload{
{Name: "N1.sh", ScriptContents: []byte("foo")},
{Name: "N2.sh", ScriptContents: []byte("bar")},
})
// successfully apply scripts for "no team"
saveAndCheckScripts(nil, []fleet.ScriptPayload{
{Name: "N1.sh", ScriptContents: []byte("foo")},
{Name: "N2.sh", ScriptContents: []byte("bar")},
})
// edit, delete and add a new one for "no team"
saveAndCheckScripts(nil, []fleet.ScriptPayload{
{Name: "N2.sh", ScriptContents: []byte("bar-edited")},
{Name: "N3.sh", ScriptContents: []byte("baz")},
})
// edit, delete and add a new one for the team
saveAndCheckScripts(tm, []fleet.ScriptPayload{
{Name: "N2.sh", ScriptContents: []byte("bar-edited")},
{Name: "N3.sh", ScriptContents: []byte("baz")},
})
// remove all scripts for a team
saveAndCheckScripts(tm, nil)
// remove all scripts for "no team"
saveAndCheckScripts(nil, nil)
}
func (s *integrationEnterpriseTestSuite) TestTeamConfigDetailQueriesOverrides() {
ctx := context.Background()
t := s.T()
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
s.Do("POST", "/api/latest/fleet/teams", team, http.StatusOK)
spec := []byte(fmt.Sprintf(`
name: %s
features:
additional_queries:
time: SELECT * FROM time
enable_host_users: true
detail_query_overrides:
users: null
software_linux: "select * from blah;"
disk_encryption_linux: null
`, teamName))
s.applyTeamSpec(spec)
team, err := s.ds.TeamByName(ctx, teamName)
require.NoError(t, err)
require.NotNil(t, team.Config.Features.DetailQueryOverrides)
require.Nil(t, team.Config.Features.DetailQueryOverrides["users"])
require.Nil(t, team.Config.Features.DetailQueryOverrides["disk_encryption_linux"])
require.NotNil(t, team.Config.Features.DetailQueryOverrides["software_linux"])
require.Equal(t, "select * from blah;", *team.Config.Features.DetailQueryOverrides["software_linux"])
// create a linux host
linuxHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now().Add(-10 * time.Hour),
LabelUpdatedAt: time.Now().Add(-10 * time.Hour),
PolicyUpdatedAt: time.Now().Add(-10 * time.Hour),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
// add the host to team1
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{linuxHost.ID}))
require.NoError(t, err)
// get distributed queries for the host
s.lq.On("QueriesForHost", linuxHost.ID).Return(map[string]string{t.Name(): "select 1 from osquery;"}, nil)
req := getDistributedQueriesRequest{NodeKey: *linuxHost.NodeKey}
var dqResp getDistributedQueriesResponse
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.NotContains(t, dqResp.Queries, "fleet_detail_query_users")
require.NotContains(t, dqResp.Queries, "fleet_detail_query_disk_encryption_linux")
require.Contains(t, dqResp.Queries, "fleet_detail_query_software_linux")
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_distributed_query_%s", t.Name()))
spec = []byte(fmt.Sprintf(`
name: %s
features:
additional_queries:
time: SELECT * FROM time
enable_host_users: true
detail_query_overrides:
software_linux: "select * from blah;"
`, teamName))
s.applyTeamSpec(spec)
team, err = s.ds.TeamByName(ctx, teamName)
require.NoError(t, err)
require.NotNil(t, team.Config.Features.DetailQueryOverrides)
require.Nil(t, team.Config.Features.DetailQueryOverrides["users"])
require.Nil(t, team.Config.Features.DetailQueryOverrides["disk_encryption_linux"])
require.NotNil(t, team.Config.Features.DetailQueryOverrides["software_linux"])
require.Equal(t, "select * from blah;", *team.Config.Features.DetailQueryOverrides["software_linux"])
// get distributed queries for the host
req = getDistributedQueriesRequest{NodeKey: *linuxHost.NodeKey}
dqResp = getDistributedQueriesResponse{}
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.Contains(t, dqResp.Queries, "fleet_detail_query_users")
require.Contains(t, dqResp.Queries, "fleet_detail_query_disk_encryption_linux")
require.Contains(t, dqResp.Queries, "fleet_detail_query_software_linux")
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_distributed_query_%s", t.Name()))
}
func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
ctx := context.Background()
t := s.T()
softwareTitleListResultsMatch := func(want, got []fleet.SoftwareTitleListResult) {
// compare only the fields we care about
for i := range got {
require.NotZero(t, got[i].ID)
got[i].ID = 0
for j := range got[i].Versions {
require.NotZero(t, got[i].Versions[j].ID)
got[i].Versions[j].ID = 0
}
// Sort versions by version
sort.Slice(
got[i].Versions, func(a, b int) bool {
return got[i].Versions[a].Version < got[i].Versions[b].Version
},
)
}
// sort and use EqualValues instead of ElementsMatch in order
// to do a deep comparison of nested structures
sort.Slice(got, func(i, j int) bool {
return got[i].Name < got[j].Name
})
sort.Slice(want, func(i, j int) bool {
return want[i].Name < want[j].Name
})
for _, v := range got {
sort.Slice(v.Versions, func(i, j int) bool {
return v.Versions[i].Version < v.Versions[j].Version
})
}
for _, v := range want {
sort.Slice(v.Versions, func(i, j int) bool {
return v.Versions[i].Version < v.Versions[j].Version
})
}
require.EqualValues(t, want, got)
}
softwareTitlesMatch := func(want, got []fleet.SoftwareTitle) {
// compare only the fields we care about
for i := range got {
require.NotZero(t, got[i].ID)
got[i].CountsUpdatedAt = nil
got[i].ID = 0
for j := range got[i].Versions {
require.NotZero(t, got[i].Versions[j].ID)
got[i].Versions[j].ID = 0
}
// Sort versions by version
sort.Slice(
got[i].Versions, func(a, b int) bool {
return got[i].Versions[a].Version < got[i].Versions[b].Version
},
)
}
// sort and use EqualValues instead of ElementsMatch in order
// to do a deep comparison of nested structures
sort.Slice(got, func(i, j int) bool {
return got[i].Name < got[j].Name
})
sort.Slice(want, func(i, j int) bool {
return want[i].Name < want[j].Name
})
require.EqualValues(t, want, got)
}
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
tmHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + "tm"),
NodeKey: ptr.String(t.Name() + "tm"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()+"tm"),
Platform: "linux",
})
require.NoError(t, err)
// create a couple of teams and add tmHost to one
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"})
require.NoError(t, err)
require.NoError(t, s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{tmHost.ID})))
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "foo", Version: "0.0.3", Source: "homebrew"},
{Name: "bar", Version: "0.0.4", Source: "apps"},
}
_, err = s.ds.UpdateHostSoftware(context.Background(), host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(context.Background(), host, false))
soft1 := host.Software[0]
for _, item := range host.Software {
if item.Name == "bar" {
soft1 = item
break
}
}
cpes := []fleet.SoftwareCPE{{SoftwareID: soft1.ID, CPE: "somecpe"}}
_, err = s.ds.UpsertSoftwareCPEs(context.Background(), cpes)
require.NoError(t, err)
// Reload software so that 'GeneratedCPEID is set.
require.NoError(t, s.ds.LoadHostSoftware(context.Background(), host, false))
soft1 = host.Software[0]
for _, item := range host.Software {
if item.Name == "bar" {
soft1 = item
break
}
}
inserted, err := s.ds.InsertSoftwareVulnerability(
context.Background(), fleet.SoftwareVulnerability{
SoftwareID: soft1.ID,
CVE: "cve-123-123-132",
}, fleet.NVDSource,
)
require.NoError(t, err)
require.True(t, inserted)
err = s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{
{
CVE: "cve-123-123-132",
CVSSScore: ptr.Float64(7.8),
CISAKnownExploit: ptr.Bool(true),
},
})
require.NoError(t, err)
// calculate hosts counts
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
var resp listSoftwareTitlesResponse
// self-service flag is ignored if no team specified see https://github.com/fleetdm/fleet/issues/26375
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1")
require.Equal(t, 2, resp.Count)
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "foo",
Source: "homebrew",
VersionsCount: 2,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.1", Vulnerabilities: nil},
{Version: "0.0.3", Vulnerabilities: nil},
},
},
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
}, resp.SoftwareTitles)
// per_page equals 1, so we get only one item, but the total count is
// still 2
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "1",
"order_key", "name",
"order_direction", "desc",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "foo",
Source: "homebrew",
VersionsCount: 2,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.1", Vulnerabilities: nil},
{Version: "0.0.3", Vulnerabilities: nil},
},
},
}, resp.SoftwareTitles)
// get the second item
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "1",
"page", "1",
"order_key", "name",
"order_direction", "desc",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
}, resp.SoftwareTitles)
// asking for a non-existent page returns an empty list
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "1",
"page", "4",
"order_key", "name",
"order_direction", "desc",
)
require.Equal(t, 2, resp.Count)
require.Empty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles)
// asking for vulnerable only software returns the expected values
expectedVulnSoftware := []fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
}
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"vulnerable", "true",
)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles)
// vulnerable param required when using vulnerability filters
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusUnprocessableEntity, &resp,
"exploit", "true",
)
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusUnprocessableEntity, &resp,
"min_cvss_score", "1",
)
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusUnprocessableEntity, &resp,
"max_cvss_score", "10",
)
// vulnerability filters
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"exploit", "true",
"vulnerable", "true",
)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"min_cvss_score", "1",
"vulnerable", "true",
)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"min_cvss_score", "10",
"vulnerable", "true",
)
require.Zero(t, resp.Count)
require.Nil(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"max_cvss_score", "10",
"vulnerable", "true",
)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"max_cvss_score", "1",
"vulnerable", "true",
)
require.Zero(t, resp.Count)
require.Nil(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"min_cvss_score", "1",
"max_cvss_score", "10",
"vulnerable", "true",
)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"min_cvss_score", "1",
"max_cvss_score", "10",
"exploit", "true",
"vulnerable", "true",
)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles)
// request titles for team1, nothing there yet
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Equal(t, 0, resp.Count)
require.Empty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles)
// add new software for tmHost
software = []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "baz", Version: "0.0.5", Source: "deb_packages"},
}
_, err = s.ds.UpdateHostSoftware(context.Background(), tmHost.ID, software)
require.NoError(t, err)
// calculate hosts counts
hostsCountTs = time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(context.Background(), hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
// request software for the team, this time we get results
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team1.ID),
"order_key", "name",
"order_direction", "desc",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "baz",
Source: "deb_packages",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.5", Vulnerabilities: nil},
},
},
{
Name: "foo",
Source: "homebrew",
VersionsCount: 1, // NOTE: this value is 1 because the team has only 1 matching host in the team
HostsCount: 1, // NOTE: this value is 1 because the team has only 1 matching host in the team
Versions: []fleet.SoftwareVersion{
{Version: "0.0.1", Vulnerabilities: nil}, // NOTE: this only includes versions present in the team
},
},
}, resp.SoftwareTitles)
// request software for no-team, we get all results and 2 hosts for
// `"foo"`
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"order_key", "name",
"order_direction", "desc",
)
require.Equal(t, 3, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "baz",
Source: "deb_packages",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.5", Vulnerabilities: nil},
},
},
{
Name: "foo",
Source: "homebrew",
VersionsCount: 2, // NOTE: this value is 2, important because no team filter was applied
HostsCount: 2, // NOTE: this value is 2, important because no team filter was applied
Versions: []fleet.SoftwareVersion{
{Version: "0.0.1", Vulnerabilities: nil},
{Version: "0.0.3", Vulnerabilities: nil},
},
},
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
}, resp.SoftwareTitles)
// match cve by name
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "123",
)
require.Equal(t, 1, resp.Count)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
}, resp.SoftwareTitles)
// match software title by name
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ba",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
{
Name: "baz",
Source: "deb_packages",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.5", Vulnerabilities: nil},
},
},
}, resp.SoftwareTitles)
// match software title by name with leading whitespace
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", " ba",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
{
Name: "baz",
Source: "deb_packages",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.5", Vulnerabilities: nil},
},
},
}, resp.SoftwareTitles)
// match software title by name with trailing whitespace
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ba ",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
{
Name: "baz",
Source: "deb_packages",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.5", Vulnerabilities: nil},
},
},
}, resp.SoftwareTitles)
// match software title by name with leading and trailing whitespace
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", " ba ",
)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
},
{
Name: "baz",
Source: "deb_packages",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.5", Vulnerabilities: nil},
},
},
}, resp.SoftwareTitles)
// find the ID of "foo"
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "foo",
)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.SoftwareTitles, 1)
require.NotEmpty(t, resp.CountsUpdatedAt)
fooTitle := resp.SoftwareTitles[0]
require.Equal(t, "foo", fooTitle.Name)
// find the ID of "baz" (team 1)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "baz",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.SoftwareTitles, 1)
require.NotEmpty(t, resp.CountsUpdatedAt)
bazTitle := resp.SoftwareTitles[0]
require.Equal(t, "baz", bazTitle.Name)
// non-existent id is a 404
var stResp getSoftwareTitleResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles/999", getSoftwareTitleRequest{}, http.StatusNotFound, &stResp)
// valid title
stResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", fooTitle.ID), getSoftwareTitleRequest{}, http.StatusOK, &stResp)
s.NotZero(*stResp.SoftwareTitle.CountsUpdatedAt)
softwareTitlesMatch([]fleet.SoftwareTitle{
{
Name: "foo",
Source: "homebrew",
VersionsCount: 2,
HostsCount: 2,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.1", Vulnerabilities: nil, HostsCount: ptr.Uint(2)},
{Version: "0.0.3", Vulnerabilities: nil, HostsCount: ptr.Uint(1)},
},
},
}, []fleet.SoftwareTitle{*stResp.SoftwareTitle})
// valid title for team
stResp = getSoftwareTitleResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", fooTitle.ID), getSoftwareTitleRequest{}, http.StatusOK, &stResp,
"team_id", fmt.Sprintf("%d", team1.ID),
)
softwareTitlesMatch(
[]fleet.SoftwareTitle{
{
Name: "foo",
Source: "homebrew",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{Version: "0.0.1", Vulnerabilities: nil, HostsCount: ptr.Uint(1)},
},
},
}, []fleet.SoftwareTitle{*stResp.SoftwareTitle},
)
// find the ID of "bar"
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "bar",
)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.SoftwareTitles, 1)
barTitle := resp.SoftwareTitles[0]
require.Equal(t, "bar", barTitle.Name)
// valid title with vulnerabilities
expected := []fleet.SoftwareTitle{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{
Version: "0.0.4",
Vulnerabilities: &fleet.SliceString{"cve-123-123-132"},
HostsCount: ptr.Uint(1),
},
},
},
}
// Global
stResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", barTitle.ID), getSoftwareTitleRequest{}, http.StatusOK, &stResp)
softwareTitlesMatch(expected, []fleet.SoftwareTitle{*stResp.SoftwareTitle})
// No Team
stResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", barTitle.ID), getSoftwareTitleRequest{}, http.StatusOK, &stResp,
"team_id", "0")
softwareTitlesMatch(expected, []fleet.SoftwareTitle{*stResp.SoftwareTitle})
// invalid title for team
stResp = getSoftwareTitleResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", barTitle.ID), getSoftwareTitleRequest{}, http.StatusNotFound, &stResp,
"team_id", fmt.Sprintf("%d", team1.ID),
)
// invalid title for no team
stResp = getSoftwareTitleResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", bazTitle.ID), getSoftwareTitleRequest{}, http.StatusNotFound, &stResp,
"team_id", "0")
// add bar tmHost
software = []fleet.Software{
{Name: "bar", Version: "0.0.4", Source: "apps"},
}
_, err = s.ds.UpdateHostSoftware(context.Background(), tmHost.ID, software)
require.NoError(t, err)
// calculate hosts counts
hostsCountTs = time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(context.Background(), hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
// valid title with vulnerabilities
stResp = getSoftwareTitleResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", barTitle.ID), getSoftwareTitleRequest{}, http.StatusOK, &stResp,
"team_id", fmt.Sprintf("%d", team1.ID),
)
softwareTitlesMatch(
[]fleet.SoftwareTitle{
{
Name: "bar",
Source: "apps",
VersionsCount: 1,
HostsCount: 1,
Versions: []fleet.SoftwareVersion{
{
Version: "0.0.4",
Vulnerabilities: &fleet.SliceString{"cve-123-123-132"},
HostsCount: ptr.Uint(1),
},
},
},
}, []fleet.SoftwareTitle{*stResp.SoftwareTitle},
)
// Team without hosts
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", barTitle.ID), getSoftwareTitleRequest{}, http.StatusNotFound, &stResp,
"team_id", fmt.Sprintf("%d", team2.ID),
)
// Non-existent team
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", barTitle.ID), getSoftwareTitleRequest{}, http.StatusNotFound, &stResp,
"team_id", "99999",
)
// verify that software installers contain SoftwarePackage field
payloadRubyTm1 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby.deb",
SelfService: false,
TeamID: &team1.ID,
}
s.uploadSoftwareInstaller(t, payloadRubyTm1, http.StatusOK, "")
payloadEmacsMissingSecret := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install $FLEET_SECRET_INVALID",
Filename: "emacs.deb",
PostInstallScript: "d",
SelfService: true,
}
s.uploadSoftwareInstallerWithErrorNameReason(t, payloadEmacsMissingSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID",
"install script")
payloadEmacsMissingPostSecret := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "emacs.deb",
PostInstallScript: "d $FLEET_SECRET_INVALID",
SelfService: true,
}
s.uploadSoftwareInstallerWithErrorNameReason(t, payloadEmacsMissingPostSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID",
"post-install script")
payloadEmacsMissingUnSecret := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "emacs.deb",
PostInstallScript: "d",
UninstallScript: "delet $FLEET_SECRET_INVALID",
SelfService: true,
}
s.uploadSoftwareInstallerWithErrorNameReason(t, payloadEmacsMissingUnSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID",
"uninstall script")
// not specifiying a team_id translates to "no team" or team_id of 0
payloadEmacs := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "emacs.deb",
SelfService: true,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, payloadEmacs, http.StatusOK, "")
payloadVim := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "vim.deb",
SelfService: true,
TeamID: ptr.Uint(0),
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, payloadVim, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
// Test that the software titles endpoint returns a SHA256 hash.
require.Equal(t, *resp.SoftwareTitles[0].HashSHA256, "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628")
// Upload an installer for the same software but different arch to a different team
payloadRubyTm2 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby_arm64.deb",
TeamID: &team2.ID,
}
s.uploadSoftwareInstaller(t, payloadRubyTm2, http.StatusOK, "")
// We should only see the one we uploaded to team 1
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
// software installer not returned with self-service only (not marked as such)
resp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp,
"self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID))
require.Len(t, resp.SoftwareTitles, 0)
// update it to be self-service, check that it gets returned
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payloadRubyTm1.Filename)
return err
})
resp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp,
"self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID))
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService)
require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService)
// "All teams" returns all software regardless of self_service see https://github.com/fleetdm/fleet/issues/26375
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"self_service", "true",
)
require.Equal(t, resp.Count, 2)
// "No team" returns the emacs software
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"self_service", "true",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 2)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService)
require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService)
require.NotNil(t, resp.SoftwareTitles[1].SoftwarePackage)
require.NotNil(t, resp.SoftwareTitles[1].SoftwarePackage.SelfService)
require.True(t, *resp.SoftwareTitles[1].SoftwarePackage.SelfService)
emacsPath := fmt.Sprintf("/api/latest/fleet/software/titles/%d", resp.SoftwareTitles[0].ID)
respTitle := getSoftwareTitleResponse{}
s.DoJSON("GET", emacsPath, listSoftwareTitlesRequest{}, http.StatusOK, &respTitle)
require.NotNil(t, respTitle.SoftwareTitle)
require.Equal(t, "emacs.deb", respTitle.SoftwareTitle.SoftwarePackage.Name)
require.True(t, respTitle.SoftwareTitle.SoftwarePackage.SelfService)
}
func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() {
ctx := context.Background()
t := s.T()
// create a Windows and a Linux hosts
winHost := createOrbitEnrolledHost(t, "windows", "win_lock_unlock", s.ds)
linuxHost := createOrbitEnrolledHost(t, "linux", "linux_lock_unlock", s.ds)
// get the host's information
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", winHost.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", linuxHost.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// try to lock/unlock/wipe the Windows host, fails because Windows MDM must be enabled
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", winHost.ID), nil, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM isn't turned on.")
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", winHost.ID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM isn't turned on.")
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", winHost.ID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM isn't turned on.")
// Disable scripts on Linux host
err := s.ds.SetOrUpdateHostOrbitInfo(
context.Background(), linuxHost.ID, "1.22.0", sql.NullString{}, sql.NullBool{Bool: false, Valid: true},
)
require.NoError(t, err)
// try to lock/unlock/wipe the Linux host. Fails because scripts are not enabled.
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", linuxHost.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't lock host. To lock, deploy the fleetd agent with --enable-scripts")
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", linuxHost.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't unlock host. To unlock, deploy the fleetd agent with --enable-scripts")
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", linuxHost.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't wipe host. To wipe, deploy the fleetd agent with --enable-scripts")
// Enable scripts on Linux host
err = s.ds.SetOrUpdateHostOrbitInfo(
context.Background(), linuxHost.ID, "1.22.0", sql.NullString{}, sql.NullBool{Bool: true, Valid: true},
)
require.NoError(t, err)
// try to lock/unlock/wipe the Linux host succeeds, no MDM constraints
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", linuxHost.ID), nil, http.StatusOK)
// simulate a successful script result for the lock command
status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost)
require.NoError(t, err)
var orbitScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *linuxHost.OrbitNodeKey, status.LockScript.ExecutionID)),
http.StatusOK, &orbitScriptResp)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", linuxHost.ID), nil, http.StatusOK)
// windows host status is unchanged, linux is locked pending unlock
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", winHost.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", linuxHost.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction)
// attempting to Wipe the linux host fails due to pending unlock, not because
// of MDM not enabled
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", linuxHost.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Host cannot be wiped until unlock is complete.")
}
// checks that the specified team/no-team has the Windows OS Updates profile with
// the specified deadline/grace settings (or checks that it doesn't have the
// profile if wantSettings is nil). It returns the profile_uuid if it exists,
// empty string otherwise.
func checkWindowsOSUpdatesProfile(t *testing.T, ds *mysql.Datastore, teamID *uint, wantSettings *fleet.WindowsUpdates) string {
ctx := context.Background()
var prof fleet.MDMWindowsConfigProfile
mysql.ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
var globalOrTeamID uint
if teamID != nil {
globalOrTeamID = *teamID
}
err := sqlx.GetContext(ctx, tx, &prof, `SELECT profile_uuid, syncml FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, mdm.FleetWindowsOSUpdatesProfileName)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
})
if wantSettings == nil {
require.Empty(t, prof.ProfileUUID)
} else {
require.NotEmpty(t, prof.ProfileUUID)
require.Contains(t, string(prof.SyncML), fmt.Sprintf(`%d`, wantSettings.DeadlineDays.Value))
require.Contains(t, string(prof.SyncML), fmt.Sprintf(`%d`, wantSettings.GracePeriodDays.Value))
}
if len(prof.ProfileUUID) > 0 {
require.Equal(t, byte('w'), prof.ProfileUUID[0])
}
return prof.ProfileUUID
}
func (s *integrationEnterpriseTestSuite) createHosts(t *testing.T, platforms ...string) []*fleet.Host {
var hosts []*fleet.Host
if len(platforms) == 0 {
platforms = []string{"debian", "rhel", "linux", "windows", "darwin"}
}
for i, platform := range platforms {
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Duration(i) * time.Minute),
OsqueryHostID: ptr.String(fmt.Sprintf("%s%d", t.Name(), i)),
NodeKey: ptr.String(fmt.Sprintf("%s%d", t.Name(), i)),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local%d", t.Name(), i),
Platform: platform,
})
require.NoError(t, err)
hosts = append(hosts, host)
}
return hosts
}
func (s *integrationEnterpriseTestSuite) TestSoftwareAuth() {
t := s.T()
ctx := context.Background()
// create two hosts, one belongs to team1 and one has no team
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
tmHost, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + "tm"),
NodeKey: ptr.String(t.Name() + "tm"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()+"tm"),
Platform: "linux",
})
require.NoError(t, err)
// Create two teams, team1 and team2.
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
ID: 42,
Name: "team1",
Description: "desc team1",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{tmHost.ID}))
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{
ID: 43,
Name: "team2",
Description: "desc team2",
})
require.NoError(t, err)
allSoftware := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "foo", Version: "0.0.3", Source: "homebrew"},
{Name: "bar", Version: "0.0.4", Source: "apps"},
}
// add all the software entries to the "no team host"
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, allSoftware)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, host, false))
// add only one version of "foo" to the team host
_, err = s.ds.UpdateHostSoftware(ctx, tmHost.ID, []fleet.Software{allSoftware[0]})
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, tmHost, false))
// calculate hosts counts
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
// add variations of user roles to different teams
extraTestUsers := make(map[string]fleet.User)
for k, u := range map[string]struct {
Email string
GlobalRole *string
Teams *[]fleet.UserTeam
}{
"team-1-admin": {
Email: "team-1-admin@example.com",
Teams: &([]fleet.UserTeam{{
Team: *team1,
Role: fleet.RoleAdmin,
}}),
},
"team-1-maintainer": {
Email: "team-1-maintainer@example.com",
Teams: &([]fleet.UserTeam{{
Team: *team1,
Role: fleet.RoleMaintainer,
}}),
},
"team-1-observer": {
Email: "team-1-observer@example.com",
Teams: &([]fleet.UserTeam{{
Team: *team1,
Role: fleet.RoleObserver,
}}),
},
"team-2-admin": {
Email: "team-2-admin@example.com",
Teams: &([]fleet.UserTeam{{
Team: *team2,
Role: fleet.RoleAdmin,
}}),
},
"team-2-maintainer": {
Email: "team-2-maintainer@example.com",
Teams: &([]fleet.UserTeam{{
Team: *team2,
Role: fleet.RoleMaintainer,
}}),
},
"team-2-observer": {
Email: "team-2-observer@example.com",
Teams: &([]fleet.UserTeam{{
Team: *team2,
Role: fleet.RoleObserver,
}}),
},
} {
uu := u
cur := createUserResponse{}
s.DoJSON("POST", "/api/latest/fleet/users/admin", createUserRequest{
UserPayload: fleet.UserPayload{
Email: &uu.Email,
Password: &test.GoodPassword,
Name: &uu.Email,
Teams: uu.Teams,
AdminForcedPasswordReset: ptr.Bool(false),
},
}, http.StatusOK, &cur)
extraTestUsers[k] = *cur.User
}
// List all software titles with an admin
var listSoftwareTitlesResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listSoftwareTitlesResp)
var softwareFoo, softwareBar *fleet.SoftwareTitleListResult
for _, s := range listSoftwareTitlesResp.SoftwareTitles {
s := s
switch s.Name {
case "foo":
softwareFoo = &s
case "bar":
softwareBar = &s
}
}
require.NotNil(t, softwareFoo)
require.NotNil(t, softwareBar)
var teamFooVersion *fleet.SoftwareVersion
for _, sv := range softwareFoo.Versions {
sv := sv
if sv.Version == "0.0.1" {
teamFooVersion = &sv
}
}
require.NotNil(t, teamFooVersion)
for _, tc := range []struct {
name string
user fleet.User
shouldFailGlobalRead bool
shouldFailTeamRead bool
}{
{
name: "global-admin",
user: s.users["admin1@example.com"],
shouldFailGlobalRead: false,
shouldFailTeamRead: false,
},
{
name: "global-maintainer",
user: s.users["user1@example.com"],
shouldFailGlobalRead: false,
shouldFailTeamRead: false,
},
{
name: "global-observer",
user: s.users["user2@example.com"],
shouldFailGlobalRead: false,
shouldFailTeamRead: false,
},
{
name: "team-admin-belongs-to-team",
user: extraTestUsers["team-1-admin"],
shouldFailGlobalRead: true,
shouldFailTeamRead: false,
},
{
name: "team-maintainer-belongs-to-team",
user: extraTestUsers["team-1-maintainer"],
shouldFailGlobalRead: true,
shouldFailTeamRead: false,
},
{
name: "team-observer-belongs-to-team",
user: extraTestUsers["team-1-observer"],
shouldFailGlobalRead: true,
shouldFailTeamRead: false,
},
{
name: "team-admin-does-not-belong-to-team",
user: extraTestUsers["team-2-admin"],
shouldFailGlobalRead: true,
shouldFailTeamRead: true,
},
{
name: "team-maintainer-does-not-belong-to-team",
user: extraTestUsers["team-2-maintainer"],
shouldFailGlobalRead: true,
shouldFailTeamRead: true,
},
{
name: "team-observer-does-not-belong-to-team",
user: extraTestUsers["team-2-observer"],
shouldFailGlobalRead: true,
shouldFailTeamRead: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
// to make the request as the user
s.token = s.getTestToken(tc.user.Email, test.GoodPassword)
if tc.shouldFailGlobalRead {
// List all software titles
var listSoftwareTitlesResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusForbidden, &listSoftwareTitlesResp)
// List all software versions
var resp listSoftwareVersionsResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareTitlesRequest{}, http.StatusForbidden, &resp)
// Get a global software title
var getSoftwareTitleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareBar.ID), getSoftwareTitleRequest{}, http.StatusForbidden, &getSoftwareTitleResp)
// Get a global software version
var getSoftwareResp getSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/versions/%d", softwareBar.Versions[0].ID), getSoftwareRequest{}, http.StatusForbidden, &getSoftwareResp)
// Get a global software vesion using the deprecated endpoint
getSoftwareResp = getSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/%d", softwareBar.Versions[0].ID), getSoftwareRequest{}, http.StatusForbidden, &getSoftwareResp)
// Get a count of software vesions using the deprecated endpoint
var countSoftwareResp countSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software/count", getSoftwareRequest{}, http.StatusForbidden, &countSoftwareResp)
// List all software versions using the deprecated endpoint
var softwareListResp listSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software", listSoftwareRequest{}, http.StatusForbidden, &softwareListResp)
} else {
// List all software titles
var listSoftwareTitlesResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listSoftwareTitlesResp)
require.Equal(t, 2, listSoftwareTitlesResp.Count)
require.NotEmpty(t, listSoftwareTitlesResp.CountsUpdatedAt)
// List all software versions
var resp listSoftwareVersionsResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareRequest{}, http.StatusOK, &resp)
require.Equal(t, 3, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
// Get a global software title
var getSoftwareTitleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareBar.ID), getSoftwareTitleRequest{}, http.StatusOK, &getSoftwareTitleResp)
// Get a global software version
var getSoftwareResp getSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/versions/%d", softwareBar.Versions[0].ID), getSoftwareRequest{}, http.StatusOK, &getSoftwareResp)
// Get a global software vesion using the deprecated endpoint
getSoftwareResp = getSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/%d", softwareBar.Versions[0].ID), getSoftwareRequest{}, http.StatusOK, &getSoftwareResp)
// Get a global count of software vesions using the deprecated endpoint
var countSoftwareResp countSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software/count", countSoftwareRequest{}, http.StatusOK, &countSoftwareResp)
require.Equal(t, 3, countSoftwareResp.Count)
// List all software versions using the deprecated endpoint
var softwareListResp listSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software", listSoftwareRequest{}, http.StatusOK, &softwareListResp)
require.Equal(t, countSoftwareResp.Count, 3)
}
if tc.shouldFailTeamRead {
// List all software titles for a team
var listSoftwareTitlesResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{SoftwareTitleListOptions: fleet.SoftwareTitleListOptions{TeamID: &team1.ID}}, http.StatusForbidden, &listSoftwareTitlesResp)
// List software versions for a team.
var resp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareRequest{SoftwareListOptions: fleet.SoftwareListOptions{TeamID: &team1.ID}}, http.StatusForbidden, &resp)
// Get a team software title
var getSoftwareTitleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareFoo.ID), getSoftwareTitleRequest{}, http.StatusForbidden, &getSoftwareTitleResp)
// Get a team software version
var getSoftwareResp getSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/versions/%d", teamFooVersion.ID), getSoftwareRequest{}, http.StatusForbidden, &getSoftwareResp)
// Get a team software vesion using the deprecated endpoint
getSoftwareResp = getSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/%d", teamFooVersion.ID), getSoftwareRequest{}, http.StatusForbidden, &getSoftwareResp)
// Get a count of team software vesions using the deprecated endpoint
var countSoftwareResp countSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software/count", getSoftwareRequest{}, http.StatusForbidden, &countSoftwareResp)
// List all software versions using the deprecated endpoint for a team
var softwareListResp listSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software", listSoftwareRequest{}, http.StatusForbidden, &softwareListResp)
} else {
// List all software titles for a team
var listSoftwareTitlesResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{SoftwareTitleListOptions: fleet.SoftwareTitleListOptions{TeamID: &team1.ID}}, http.StatusOK, &listSoftwareTitlesResp)
require.Equal(t, 1, listSoftwareTitlesResp.Count)
require.NotEmpty(t, listSoftwareTitlesResp.CountsUpdatedAt)
// List software versions for a team.
var resp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareRequest{SoftwareListOptions: fleet.SoftwareListOptions{TeamID: &team1.ID}}, http.StatusOK, &resp)
require.Equal(t, 1, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
// Get a team software title
var getSoftwareTitleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareFoo.ID), getSoftwareTitleRequest{}, http.StatusOK, &getSoftwareTitleResp)
// Get a team software version
var getSoftwareResp getSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/versions/%d", teamFooVersion.ID), getSoftwareRequest{}, http.StatusOK, &getSoftwareResp)
// Get a team software vesion using the deprecated endpoint
getSoftwareResp = getSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/%d", teamFooVersion.ID), getSoftwareRequest{}, http.StatusOK, &getSoftwareResp)
// Get a team count of software vesions using the deprecated endpoint
var countSoftwareResp countSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software/count", countSoftwareRequest{SoftwareListOptions: fleet.SoftwareListOptions{TeamID: &team1.ID}}, http.StatusOK, &countSoftwareResp)
require.Equal(t, 1, countSoftwareResp.Count)
// List all software versions using the deprecated endpoint for a team
var softwareListResp listSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/software", listSoftwareRequest{SoftwareListOptions: fleet.SoftwareListOptions{TeamID: &team1.ID}}, http.StatusOK, &softwareListResp)
require.Equal(t, countSoftwareResp.Count, 1)
}
})
}
// set the admin token again to avoid breaking other tests
s.token = s.getTestAdminToken()
}
func genDistributedReqWithPolicyResults(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim {
var (
results = make(map[string]json.RawMessage)
statuses = make(map[string]interface{})
messages = make(map[string]string)
)
for policyID, policyResult := range policyResults {
distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID)
switch {
case policyResult == nil:
results[distributedQueryName] = json.RawMessage(`[]`)
statuses[distributedQueryName] = 1
messages[distributedQueryName] = "policy failed execution"
case *policyResult:
results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`)
statuses[distributedQueryName] = 0
case !*policyResult:
results[distributedQueryName] = json.RawMessage(`[]`)
statuses[distributedQueryName] = 0
}
}
return submitDistributedQueryResultsRequestShim{
NodeKey: *host.NodeKey,
Results: results,
Statuses: statuses,
Messages: messages,
Stats: map[string]*fleet.Stats{},
}
}
func (s *integrationEnterpriseTestSuite) TestCalendarEvents() {
ctx := context.Background()
t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
calendar.ClearMockChannels()
})
currentAppCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err = s.ds.SaveAppConfig(ctx, currentAppCfg)
require.NoError(t, err)
})
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team1",
})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team2",
})
require.NoError(t, err)
newHost := func(name string, teamID *uint) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
return h
}
host1Team1 := newHost("host1", &team1.ID)
host2Team1 := newHost("host2", &team1.ID)
host3Team2 := newHost("host3", &team2.ID)
host4Team2 := newHost("host4", &team2.ID)
_ = newHost("host5", nil) // global host
team1Policy1Calendar, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy1Calendar",
Query: "SELECT 1;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
team1Policy2, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy2",
Query: "SELECT 2;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
team2Policy1Calendar, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team2Policy1Calendar",
Query: "SELECT 3;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
team2Policy2, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team2Policy2",
Query: "SELECT 4;",
CalendarEventsEnabled: false,
},
)
require.NoError(t, err)
globalPolicy, err := s.ds.NewGlobalPolicy(
ctx, nil, fleet.PolicyPayload{
Name: "globalPolicy",
Query: "SELECT 5;",
CalendarEventsEnabled: false,
},
)
require.NoError(t, err)
// host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global).
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host3Team2 is passing team2Policy1Calendar and failing the global policy
// (not results for team2Policy2).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
team2Policy1Calendar.ID: ptr.Bool(true),
team2Policy2.ID: nil,
globalPolicy.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// host4Team2 is not returning results for the calendar policy, failing the non-calendar
// policy and passing the global policy.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host4Team2,
map[uint]*bool{
team2Policy1Calendar.ID: nil,
team2Policy2.ID: ptr.Bool(false),
globalPolicy.ID: ptr.Bool(true),
},
), http.StatusOK, &distributedResp)
// Trigger the calendar cron with the global feature is disabled.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// No calendar events were created.
allCalendarEvents, err := s.ds.ListCalendarEvents(ctx, nil)
require.NoError(t, err)
require.Empty(t, allCalendarEvents)
// Set global configuration for the calendar feature.
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.Integrations.GoogleCalendar = []*fleet.GoogleCalendarIntegration{
{
Domain: "example.com",
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: "calendar-mock@example.com",
},
},
}
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
time.Sleep(2 * time.Second) // Wait 2 seconds for the app config cache to clear.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// No calendar events were created because we are missing enabling it on the teams.
allCalendarEvents, err = s.ds.ListCalendarEvents(ctx, nil)
require.NoError(t, err)
require.Empty(t, allCalendarEvents)
// Run distributed/write for host4Team2 again, it should not attempt to trigger the webhook because
// it's disabled for the teams.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host4Team2,
map[uint]*bool{
team2Policy1Calendar.ID: nil,
team2Policy2.ID: ptr.Bool(false),
globalPolicy.ID: ptr.Bool(true),
},
), http.StatusOK, &distributedResp)
var (
team1Fired int
team1FiredMu sync.Mutex
)
team1WebhookFired := make(chan struct{})
team1WebhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
requestBodyBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
t.Logf("team1 webhook request: %s\n", requestBodyBytes)
team1FiredMu.Lock()
team1Fired++
team1WebhookFired <- struct{}{}
team1FiredMu.Unlock()
}))
t.Cleanup(func() {
team1WebhookServer.Close()
})
team1.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: team1WebhookServer.URL,
}
team1, err = s.ds.SaveTeam(ctx, team1)
require.NoError(t, err)
var (
team2Fired int
team2FiredMu sync.Mutex
)
team2WebhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
requestBodyBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
t.Logf("team2 webhook request: %s\n", requestBodyBytes)
team2FiredMu.Lock()
team2Fired++
team2FiredMu.Unlock()
}))
t.Cleanup(func() {
team2WebhookServer.Close()
})
team2.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: team2WebhookServer.URL,
}
_, err = s.ds.SaveTeam(ctx, team2)
require.NoError(t, err)
//
// Same distributed/write as before but they should not fire yet.
//
// host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host3Team2 is passing team2Policy1Calendar and failing the global policy
// (not results for team2Policy2).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
team2Policy1Calendar.ID: ptr.Bool(true),
team2Policy2.ID: nil,
globalPolicy.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// host4Team2 is not returning results for the calendar policy, failing the non-calendar
// policy and passing the global policy.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host4Team2,
map[uint]*bool{
team2Policy1Calendar.ID: nil,
team2Policy2.ID: ptr.Bool(false),
globalPolicy.ID: ptr.Bool(true),
},
), http.StatusOK, &distributedResp)
team1FiredMu.Lock()
require.Zero(t, team1Fired)
team1FiredMu.Unlock()
team2FiredMu.Lock()
require.Zero(t, team2Fired)
team2FiredMu.Unlock()
// Trigger the calendar cron, global feature enabled, team1 enabled, team2 not yet enabled
// and hosts do not have an associated email yet.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
team1CalendarEvents, err := s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Empty(t, team1CalendarEvents)
// Add an email but of another domain.
err = s.ds.ReplaceHostDeviceMapping(ctx, host1Team1.ID, []*fleet.HostDeviceMapping{
{
HostID: host1Team1.ID,
Email: "user@other.com",
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
// Trigger the calendar cron, global feature enabled, team1 enabled, team2 not yet enabled
// and hosts do not have an associated email for the domain yet.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Empty(t, team1CalendarEvents)
err = s.ds.ReplaceHostDeviceMapping(ctx, host1Team1.ID, []*fleet.HostDeviceMapping{
{
HostID: host1Team1.ID,
Email: "user1@example.com",
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
// Trigger the calendar cron, global feature enabled, team1 enabled, team2 not yet enabled
// and host1Team1 has a domain email associated.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// An event should be generated for host1Team1
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
require.NotZero(t, team1CalendarEvents[0].ID)
require.Equal(t, "user1@example.com", team1CalendarEvents[0].Email)
require.NotZero(t, team1CalendarEvents[0].StartTime)
require.NotZero(t, team1CalendarEvents[0].EndTime)
calendar.SetMockEventsToNow()
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
// Update updated_at so the event gets updated (the event is updated regularly)
_, err := db.ExecContext(ctx,
`UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 HOUR) WHERE id = ?`, team1CalendarEvents[0].ID)
if err != nil {
return err
}
// Set host1Team1 as online.
if _, err := db.ExecContext(ctx,
`UPDATE host_seen_times SET seen_time = CURRENT_TIMESTAMP WHERE host_id = ?`, host1Team1.ID); err != nil {
return err
}
return nil
})
// Trigger the calendar cron, global feature enabled, team1 enabled, team2 not yet enabled
// and host1Team1 has a domain email associated.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// Check that refetch on the host was set.
host, err := s.ds.Host(ctx, host1Team1.ID)
require.NoError(t, err)
require.True(t, host.RefetchRequested)
// host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
select {
case <-team1WebhookFired:
case <-time.After(5 * time.Second):
t.Error("timeout waiting for team1 webhook to fire")
}
// Trigger again, nothing should fire as webhook has already fired.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
team1FiredMu.Lock()
require.Equal(t, 1, team1Fired)
team1FiredMu.Unlock()
team2FiredMu.Lock()
require.Equal(t, 0, team2Fired)
team2FiredMu.Unlock()
// Make host1Team1 pass all policies.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// Trigger calendar should cleanup the events.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// Events in the user calendar should not be cleaned up because they are not in the future.
mockEvents := calendar.ListGoogleMockEvents()
require.NotEmpty(t, mockEvents)
// Event should be cleaned up from our database.
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Empty(t, team1CalendarEvents)
}
func (s *integrationEnterpriseTestSuite) TestCalendarEventsTransferringHosts() {
ctx := context.Background()
t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
calendar.ClearMockChannels()
})
currentAppCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err = s.ds.SaveAppConfig(ctx, currentAppCfg)
require.NoError(t, err)
})
// Set global configuration for the calendar feature.
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.Integrations.GoogleCalendar = []*fleet.GoogleCalendarIntegration{
{
Domain: "example.com",
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: "calendar-mock@example.com",
},
},
}
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
time.Sleep(2 * time.Second) // Wait 2 seconds for the app config cache to clear.
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team1",
})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team2",
})
require.NoError(t, err)
team1.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: "https://foo.example.com",
}
team1, err = s.ds.SaveTeam(ctx, team1)
require.NoError(t, err)
team2.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: "https://foo.example.com",
}
team2, err = s.ds.SaveTeam(ctx, team2)
require.NoError(t, err)
newHost := func(name string, teamID *uint) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
return h
}
host1 := newHost("host1", &team1.ID)
err = s.ds.ReplaceHostDeviceMapping(ctx, host1.ID, []*fleet.HostDeviceMapping{
{
HostID: host1.ID,
Email: "user1@example.com",
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
team1Policy1, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy1",
Query: "SELECT 1;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
team2Policy1, err := s.ds.NewTeamPolicy(
ctx, team2.ID, nil, fleet.PolicyPayload{
Name: "team2Policy1",
Query: "SELECT 2;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1,
map[uint]*bool{
team1Policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
team1CalendarEvents, err := s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
// Check the calendar was created on the DB.
hostCalendarEvent, calendarEvent, err := s.ds.GetHostCalendarEventByEmail(ctx, "user1@example.com")
require.NoError(t, err)
// Transfer host to team2.
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team2.ID, []uint{host1.ID}))
require.NoError(t, err)
// host1 is failing team2's policy too.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1,
map[uint]*bool{
team2Policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// Check the calendar event entry was reused.
hostCalendarEvent2, calendarEvent2, err := s.ds.GetHostCalendarEventByEmail(ctx, "user1@example.com")
require.NoError(t, err)
require.Equal(t, calendarEvent2.ID, calendarEvent.ID)
require.Equal(t, hostCalendarEvent2.CalendarEventID, hostCalendarEvent.CalendarEventID)
// Transfer host to global.
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(nil, []uint{host1.ID}))
require.NoError(t, err)
// Move event to two days ago (to clean up the calendar event)
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(ctx,
`UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 49 HOUR) WHERE id = ?`, team1CalendarEvents[0].ID)
if err != nil {
return err
}
return nil
})
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// Calendar event is cleaned up.
_, _, err = s.ds.GetHostCalendarEventByEmail(ctx, "user1@example.com")
require.True(t, fleet.IsNotFound(err))
}
func (s *integrationEnterpriseTestSuite) TestLabelsHostsCounts() {
// ensure that on exit, the admin token is used
defer func() { s.token = s.getTestAdminToken() }()
t := s.T()
ctx := context.Background()
hosts := s.createHosts(t, "debian", "linux", "fedora", "darwin", "darwin")
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
// move a couple hosts to tm1, one to tm2
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm1.ID, []uint{hosts[0].ID, hosts[1].ID}))
require.NoError(t, err)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm2.ID, []uint{hosts[2].ID}))
require.NoError(t, err)
// create new users for tm1, tm2 and one with both tm1 and tm2
users := []fleet.UserPayload{
{
Name: ptr.String("team1 user"),
Email: ptr.String("tm1user@example.com"),
Password: ptr.String(test.GoodPassword),
AdminForcedPasswordReset: ptr.Bool(false),
Teams: &[]fleet.UserTeam{
{Team: fleet.Team{ID: tm1.ID}, Role: fleet.RoleMaintainer},
},
},
{
Name: ptr.String("team2 user"),
Email: ptr.String("tm2user@example.com"),
Password: ptr.String(test.GoodPassword),
AdminForcedPasswordReset: ptr.Bool(false),
Teams: &[]fleet.UserTeam{
{Team: fleet.Team{ID: tm2.ID}, Role: fleet.RoleAdmin},
},
},
{
Name: ptr.String("team1and2 user"),
Email: ptr.String("tm1and2user@example.com"),
Password: ptr.String(test.GoodPassword),
AdminForcedPasswordReset: ptr.Bool(false),
Teams: &[]fleet.UserTeam{
{Team: fleet.Team{ID: tm1.ID}, Role: fleet.RoleObserver},
{Team: fleet.Team{ID: tm2.ID}, Role: fleet.RoleObserverPlus},
},
},
}
for _, u := range users {
var createResp createUserResponse
s.DoJSON("POST", "/api/latest/fleet/users/admin", u, http.StatusOK, &createResp)
}
// create a manual label with hosts across no team, team1 and team2
var createLbl createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: "manual1",
Hosts: []string{hosts[0].UUID, hosts[1].UUID, hosts[2].UUID, hosts[3].UUID},
},
}, http.StatusOK, &createLbl)
// user is admin, count contains all hosts
require.Equal(t, 4, createLbl.Label.Count)
lblM1 := createLbl.Label.ID
require.NotZero(t, lblM1)
// create a dynamic label always returns a count of 0 (no members yet)
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: "dynamic1",
Query: "select 1",
},
}, http.StatusOK, &createLbl)
require.Equal(t, 0, createLbl.Label.Count)
lblD1 := createLbl.Label.ID
require.NotZero(t, lblD1)
// record membership for hosts across no team, team1 and team2
err = s.ds.RecordLabelQueryExecutions(ctx, hosts[4], map[uint]*bool{lblD1: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
err = s.ds.RecordLabelQueryExecutions(ctx, hosts[2], map[uint]*bool{lblD1: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
err = s.ds.RecordLabelQueryExecutions(ctx, hosts[1], map[uint]*bool{lblD1: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
err = s.ds.RecordLabelQueryExecutions(ctx, hosts[0], map[uint]*bool{lblD1: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// create another dynamic label which will stay empty
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: "dynamic2",
Query: "select 2",
},
}, http.StatusOK, &createLbl)
require.Equal(t, 0, createLbl.Label.Count)
lblD2 := createLbl.Label.ID
require.NotZero(t, lblD2)
// test access with each team user
adminUserPayload := fleet.UserPayload{
Name: ptr.String("admin1"),
Email: ptr.String(testUsers["admin1"].Email),
Password: ptr.String(testUsers["admin1"].PlaintextPassword),
}
cases := []struct {
desc string
u fleet.UserPayload
lblID uint
want int
}{
{"team1 user, manual1", users[0], lblM1, 2},
{"team1 user, dynamic1", users[0], lblD1, 2},
{"team1 user, dynamic2", users[0], lblD2, 0},
{"team2 user, manual1", users[1], lblM1, 1},
{"team2 user, dynamic1", users[1], lblD1, 1},
{"team2 user, dynamic2", users[1], lblD2, 0},
{"team1 and 2 user, manual1", users[2], lblM1, 3},
{"team1 and 2 user, dynamic1", users[2], lblD1, 3},
{"team1 and 2 user, dynamic2", users[2], lblD2, 0},
{"admin user, manual1", adminUserPayload, lblM1, 4},
{"admin user, dynamic1", adminUserPayload, lblD1, 4},
{"admin user, dynamic2", adminUserPayload, lblD2, 0},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
s.setTokenForTest(t, *c.u.Email, *c.u.Password)
var getLbl getLabelResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", c.lblID), nil, http.StatusOK, &getLbl)
require.Equal(t, c.want, getLbl.Label.Count)
var listLbls listLabelsResponse
s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listLbls)
var found bool
for _, lbl := range listLbls.Labels {
if lbl.ID == c.lblID {
found = true
require.Equal(t, c.want, lbl.Count)
break
}
}
require.True(t, found)
// create and update label and just not possible for non-global users
if c.u != adminUserPayload && (*c.u.Teams)[0].Role != fleet.RoleMaintainer && (*c.u.Teams)[0].Role != fleet.RoleAdmin {
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: "will fail",
Query: "select 3",
},
}, http.StatusForbidden, &createLbl)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", c.lblID), modifyLabelRequest{
ModifyLabelPayload: fleet.ModifyLabelPayload{
Name: ptr.String("will fail"),
},
}, http.StatusForbidden, &modifyLabelResponse{})
}
})
}
}
func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
ctx := context.Background()
t := s.T()
token := "good_token"
host := createOrbitEnrolledHost(t, "ubuntu", "host1", s.ds)
createDeviceTokenForHost(t, s.ds, host.ID, token)
otherToken := "other_token"
otherHost := createOrbitEnrolledHost(t, "rhel", "host2", s.ds)
createDeviceTokenForHost(t, s.ds, otherHost.ID, otherToken)
// no software yet
var getHostSw getHostSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 0)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Len(t, getHostSw.Software, 0)
var getDeviceSw getDeviceSoftwareResponse
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
err := json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 0)
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 0)
// create some software for that host
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.2", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.1", Source: "deb_packages"},
}
us, err := s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
err = s.ds.ReconcileSoftwareTitles(ctx)
require.NoError(t, err)
// Note: the ID returned by ListHostSoftware is the title ID, not the software ID. We need the
// software ID to assign the vulnerabilities correctly below.
var barSoftwareID uint
for _, s := range us.Inserted {
if s.Name == "bar" {
barSoftwareID = s.ID
}
}
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 2) // foo and bar
require.Equal(t, getHostSw.Software[0].Name, "bar")
require.Equal(t, getHostSw.Software[1].Name, "foo")
require.Len(t, getHostSw.Software[1].InstalledVersions, 2)
// no package information as there is no installer
require.Nil(t, getHostSw.Software[0].SoftwarePackage)
require.Nil(t, getHostSw.Software[0].AppStoreApp)
require.Nil(t, getHostSw.Software[1].SoftwarePackage)
require.Nil(t, getHostSw.Software[1].AppStoreApp)
// Add vulnerabilities to software to check query param filtering
_, err = s.ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{SoftwareID: barSoftwareID, CVE: "CVE-bar-1234"}, fleet.NVDSource)
require.NoError(t, err)
_, err = s.ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{SoftwareID: barSoftwareID, CVE: "CVE-bar-5678"}, fleet.NVDSource)
require.NoError(t, err)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "vulnerable", "true")
require.Len(t, getHostSw.Software, 1)
require.NoError(t, err)
require.Equal(t, "bar", getHostSw.Software[0].Name)
require.Nil(t, getHostSw.Software[0].SoftwarePackage)
require.Nil(t, getHostSw.Software[0].AppStoreApp)
require.Len(t, getHostSw.Software[0].InstalledVersions, 1)
require.Len(t, getHostSw.Software[0].InstalledVersions[0].Vulnerabilities, 2)
require.Equal(t, getHostSw.Software[0].InstalledVersions[0].Vulnerabilities, []string{"CVE-bar-1234", "CVE-bar-5678"})
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 2) // foo and bar
require.Equal(t, getDeviceSw.Software[0].Name, "bar")
require.Equal(t, getDeviceSw.Software[1].Name, "foo")
require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2)
// no package information as there is no installer
require.Nil(t, getDeviceSw.Software[0].SoftwarePackage)
require.Nil(t, getDeviceSw.Software[0].AppStoreApp)
require.Nil(t, getDeviceSw.Software[1].SoftwarePackage)
require.Nil(t, getDeviceSw.Software[1].AppStoreApp)
// create a software installer, not installed on the host
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby.deb",
Version: "1:2.5.1",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages")
// update it to be self-service
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payload.Filename)
return err
})
// available installer is returned by user-authenticated endpoint
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb
require.Equal(t, getHostSw.Software[0].Name, "bar")
require.Equal(t, getHostSw.Software[1].Name, "foo")
require.Equal(t, getHostSw.Software[2].Name, "ruby")
require.Len(t, getHostSw.Software[1].InstalledVersions, 2)
require.Nil(t, getHostSw.Software[2].AppStoreApp)
require.NotNil(t, getHostSw.Software[2].SoftwarePackage)
require.Equal(t, "ruby.deb", getHostSw.Software[2].SoftwarePackage.Name)
require.Equal(t, payload.Version, getHostSw.Software[2].SoftwarePackage.Version)
require.NotNil(t, getHostSw.Software[2].SoftwarePackage.SelfService)
require.True(t, *getHostSw.Software[2].SoftwarePackage.SelfService)
require.Nil(t, getHostSw.Software[2].Status)
// user authenticated endpoint, but explicitly request to not include available for install software
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software?include_available_for_install=false", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 2) // foo and bar
require.Equal(t, getHostSw.Software[0].Name, "bar")
require.Equal(t, getHostSw.Software[1].Name, "foo")
// only the installer is returned for self-service only
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")
// available installer is not returned by device-authenticated endpoint
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 2) // foo and bar
require.Equal(t, getDeviceSw.Software[0].Name, "bar")
require.Equal(t, getDeviceSw.Software[1].Name, "foo")
require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2)
// but it gets returned for self-service only
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 1)
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
require.Nil(t, getDeviceSw.Software[0].AppStoreApp)
require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage)
require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage.SelfService)
require.True(t, *getDeviceSw.Software[0].SoftwarePackage.SelfService)
require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name)
require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version)
// =========================================
// test label scoping
// =========================================
// TODO(JVE): remove/update this once the API is in place
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(
ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
siID, labelID, exclude,
)
return err
})
}
var installerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
})
require.NotEmpty(t, installerID)
// create some labels
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label1",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label2",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
// Set to "exclude any". Installer should be missing from the response for both host details and
// for self service
updateInstallerLabel(installerID, labelResp.Label.ID, true)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Empty(t, getHostSw.Software)
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Empty(t, getDeviceSw.Software)
// Set to "include any". Installer should be in response.
updateInstallerLabel(installerID, labelResp.Label.ID, false)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 1)
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
// request installation on the host
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
host.ID, titleID), nil, http.StatusAccepted, &installResp)
// still returned by user-authenticated endpoint, now pending
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb
require.Equal(t, getHostSw.Software[0].Name, "bar")
require.Equal(t, getHostSw.Software[1].Name, "foo")
require.Equal(t, getHostSw.Software[2].Name, "ruby")
require.Len(t, getHostSw.Software[1].InstalledVersions, 2)
require.NotNil(t, getHostSw.Software[2].SoftwarePackage)
require.Equal(t, "ruby.deb", getHostSw.Software[2].SoftwarePackage.Name)
require.NotNil(t, getHostSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[2].Status)
require.NotNil(t, getHostSw.Software[2].SoftwarePackage.SelfService)
require.True(t, *getHostSw.Software[2].SoftwarePackage.SelfService)
rubyLastInstallInstallUUID := getHostSw.Software[2].SoftwarePackage.LastInstall.InstallUUID
// still returned with self-service filter
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")
// now returned by device-authenticated endpoint
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 2) // foo, bar
require.Equal(t, getDeviceSw.Software[0].Name, "bar")
require.Equal(t, getDeviceSw.Software[1].Name, "foo")
require.Len(t, getDeviceSw.Software[0].InstalledVersions, 1)
require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2)
// confirm device-auth'd install results are visible
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software/install/"+rubyLastInstallInstallUUID+"/results", nil, http.StatusOK)
getDeviceInstallResult := getSoftwareInstallResultsResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceInstallResult)
require.NoError(t, err)
require.Equal(t, "ruby", getDeviceInstallResult.Results.SoftwareTitle)
require.Equal(t, host.ID, getDeviceInstallResult.Results.HostID)
require.Equal(t, fleet.SoftwareInstallPending, getDeviceInstallResult.Results.Status)
s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software/install/nope/results", nil, http.StatusNotFound)
s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+otherToken+"/software/install/"+rubyLastInstallInstallUUID+"/results", nil, http.StatusNotFound)
// still returned for self-service only too
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 1)
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage)
require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage.SelfService)
require.True(t, *getDeviceSw.Software[0].SoftwarePackage.SelfService)
require.Nil(t, getDeviceSw.Software[0].AppStoreApp)
// test with a query
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "query", "foo")
require.Len(t, getHostSw.Software, 1) // foo only
require.Equal(t, getHostSw.Software[0].Name, "foo")
require.Len(t, getHostSw.Software[0].InstalledVersions, 2)
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?query=bar", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 1) // bar only
require.Equal(t, getDeviceSw.Software[0].Name, "bar")
require.Len(t, getDeviceSw.Software[0].InstalledVersions, 1)
// Get software available for install
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "available_for_install",
"true", "order_key", "name", "order_direction", "asc")
require.Len(t, getHostSw.Software, 1) // ruby.app
assert.Equal(t, "ruby", getHostSw.Software[0].Name)
assert.Equal(t, *getHostSw.Software[0].Status, fleet.SoftwareInstallPending)
assert.NotNil(t, getHostSw.Software[0].SoftwarePackage)
assert.Equal(t, "1:2.5.1", getHostSw.Software[0].SoftwarePackage.Version)
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndDelete() {
t := s.T()
openFile := func(name string) *os.File {
f, err := os.Open(filepath.Join("testdata", "software-installers", name))
require.NoError(t, err)
return f
}
var expectBytes []byte
var expectLen int
f := openFile("ruby.deb")
st, err := f.Stat()
require.NoError(t, err)
expectLen = int(st.Size())
require.Equal(t, expectLen, 11340)
expectBytes = make([]byte, expectLen)
n, err := f.Read(expectBytes)
require.NoError(t, err)
require.Equal(t, n, expectLen)
f.Close()
checkDownloadResponse := func(t *testing.T, r *http.Response, expectedFilename string) {
require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type"))
require.Equal(t, fmt.Sprintf(`attachment;filename="%s"`, expectedFilename), r.Header.Get("Content-Disposition"))
require.NotZero(t, r.ContentLength)
require.Equal(t, expectLen, int(r.ContentLength))
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Equal(t, expectLen, len(b))
require.Equal(t, expectBytes, b)
}
checkSoftwareTitle := func(t *testing.T, title string, source string) uint {
var id uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''`, title, source)
})
return id
}
checkScriptContentsID := func(t *testing.T, id uint, expectedContents string) {
var contents string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &contents, `SELECT contents FROM script_contents WHERE id = ?`, id)
})
require.Equal(t, expectedContents, contents)
}
checkSoftwareInstaller := func(t *testing.T, payload *fleet.UploadSoftwareInstallerPayload) (installerID uint, titleID uint) {
var tid uint
if payload.TeamID != nil {
tid = *payload.TeamID
}
var id uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, tid, payload.Filename)
})
require.NotZero(t, id)
var platform string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &platform, `SELECT platform FROM software_installers WHERE id = ?`, id)
})
require.Equal(t, payload.Platform, "linux")
meta, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), id)
require.NoError(t, err)
if payload.TeamID != nil && *payload.TeamID > 0 {
require.Equal(t, *payload.TeamID, *meta.TeamID)
} else {
require.Nil(t, meta.TeamID)
}
checkScriptContentsID(t, meta.InstallScriptContentID, payload.InstallScript)
if payload.PostInstallScript != "" {
require.NotNil(t, meta.PostInstallScriptContentID)
checkScriptContentsID(t, *meta.PostInstallScriptContentID, payload.PostInstallScript)
} else {
require.Nil(t, meta.PostInstallScriptContentID)
}
require.Equal(t, payload.PreInstallQuery, meta.PreInstallQuery)
require.Equal(t, payload.StorageID, meta.StorageID)
require.Equal(t, payload.Filename, meta.Name)
require.Equal(t, payload.Version, meta.Version)
require.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID)
require.NotZero(t, meta.UploadedAt)
// get metadata by team and title ID so we can check labels
meta2, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(context.Background(), payload.TeamID, *meta.TitleID, false)
require.NoError(t, err)
// check labels include any
require.Len(t, meta2.LabelsIncludeAny, len(payload.LabelsIncludeAny))
byName := make(map[string]struct{}, len(meta2.LabelsIncludeAny))
for _, l := range meta2.LabelsIncludeAny {
byName[l.LabelName] = struct{}{}
require.Equal(t, *meta2.TitleID, l.TitleID)
require.False(t, l.Exclude)
}
require.Len(t, byName, len(payload.LabelsIncludeAny))
for _, l := range payload.LabelsIncludeAny {
_, ok := byName[l]
require.True(t, ok)
}
// check labels exclude any
require.Len(t, meta2.LabelsExcludeAny, len(payload.LabelsExcludeAny))
byName = make(map[string]struct{}, len(meta2.LabelsExcludeAny))
for _, l := range meta2.LabelsExcludeAny {
byName[l.LabelName] = struct{}{}
require.Equal(t, *meta2.TitleID, l.TitleID)
require.True(t, l.Exclude)
}
require.Len(t, byName, len(payload.LabelsExcludeAny))
for _, l := range payload.LabelsExcludeAny {
_, ok := byName[l]
require.True(t, ok)
}
return meta.InstallerID, *meta.TitleID
}
t.Run("upload no team software installer", func(t *testing.T) {
// status is reflected in list hosts responses and counts when filtering by software title and status
// create a label to test also the counts per label with the software install status filter
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: t.Name(),
Query: "select 1",
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some install script",
PreInstallQuery: "some pre install query",
PostInstallScript: "some post install script",
Filename: "ruby.deb",
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
Title: "ruby",
Version: "1:2.5.1",
Source: "deb_packages",
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
LabelsIncludeAny: []string{t.Name()},
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// check the software installer
_, titleID := checkSoftwareInstaller(t, payload)
// check activity
activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
"team_id": null, "self_service": false, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}]}`,
titleID, labelResp.Label.ID, t.Name())
s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
// upload again fails
s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// update should succeed
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
SelfService: ptr.Bool(true),
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: titleID,
TeamID: nil,
}, http.StatusOK, "")
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null,
"team_id": null, "self_service": true, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}]}`,
titleID, labelResp.Label.ID, t.Name())
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0)
// patch the software installer to change the labels
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"team_id": {"0"},
"labels_exclude_any": {t.Name()},
})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload := *payload
expectedPayload.LabelsIncludeAny = nil
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name}
checkSoftwareInstaller(t, &expectedPayload)
// Create a host and assign the label to it
host := createOrbitEnrolledHost(t, "linux", "label_host", s.ds)
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// Attempt to install. Should fail because label is "exclude any"
resp := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.")
// patch the software installer again but this time change the pre install query and leave the labels as is
body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"team_id": {"0"},
"pre_install_query": {"some other pre install query"},
})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload.PreInstallQuery = "some other pre install query"
expectedPayload.LabelsIncludeAny = nil // no change
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} // no change
checkSoftwareInstaller(t, &expectedPayload)
// update the label to be "include any". This should allow for the installation to happen.
var b3 bytes.Buffer
w3 := multipart.NewWriter(&b3)
require.NoError(t, w3.WriteField("team_id", "0"))
require.NoError(t, w3.WriteField("pre_install_query", "some other pre install query"))
require.NoError(t, w3.WriteField("labels_include_any", labelResp.Label.Name))
w3.Close()
headers = map[string]string{
"Content-Type": w3.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), b3.Bytes(), http.StatusOK, headers)
expectedPayload.PreInstallQuery = "some other pre install query"
expectedPayload.LabelsIncludeAny = []string{labelResp.Label.Name}
expectedPayload.LabelsExcludeAny = nil
checkSoftwareInstaller(t, &expectedPayload)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted)
// update the installer succeeds
body, headers = generateMultipartRequest(t, "software",
"", []byte{}, s.token, map[string][]string{"self_service": {"true"}, "team_id": {"0"}})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null,
"team_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}], "software_title_id": %d}`,
labelResp.Label.ID, labelResp.Label.Name, titleID)
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0)
// orbit-downloading fails with invalid orbit node key
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: 123,
OrbitNodeKey: uuid.NewString(),
}, http.StatusUnauthorized)
// download the installer
s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusBadRequest)
// delete the installer from nil team fails
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusBadRequest)
// delete from team 0 succeeds
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0")
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null,
"team_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}`,
labelResp.Label.ID, labelResp.Label.Name)
s.lastActivityMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), activityData, 0)
})
t.Run("create team software installer", func(t *testing.T) {
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
Name: t.Name(),
}, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
payload := &fleet.UploadSoftwareInstallerPayload{
TeamID: &createTeamResp.Team.ID,
InstallScript: "another install script",
PreInstallQuery: "another pre install query",
PostInstallScript: "another post install script",
Filename: "ruby.deb",
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
Title: "ruby",
Version: "1:2.5.1",
Source: "deb_packages",
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
SelfService: true,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// check the software installer
installerID, titleID := checkSoftwareInstaller(t, payload)
// check activity
activityData := fmt.Sprintf(
`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true, "software_title_id": %d}`,
createTeamResp.Team.Name,
createTeamResp.Team.ID,
titleID,
)
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
// upload again fails
s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// download the installer
r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID))
checkDownloadResponse(t, r, payload.Filename)
// download the installer by getting token first
tokenResp := getSoftwareInstallerTokenResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token?alt=media", titleID), nil, http.StatusOK,
&tokenResp, "team_id", fmt.Sprintf("%d", *payload.TeamID))
require.NotEmpty(t, tokenResp.Token)
r = s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token/%s", titleID, tokenResp.Token), nil,
http.StatusOK)
checkDownloadResponse(t, r, payload.Filename)
// downloading a second time using the same token should fail
_ = s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token/%s", titleID, tokenResp.Token), nil,
http.StatusForbidden)
// alt != media should fail
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token?alt=bozo", titleID), nil,
http.StatusUnprocessableEntity,
&tokenResp, "team_id", fmt.Sprintf("%d", *payload.TeamID))
// missing team_id should fail
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token?alt=media", titleID), nil,
http.StatusBadRequest,
&tokenResp)
// create an orbit host that is not in the team
hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds)
// downloading installer doesn't work if the host doesn't have a pending install request
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostNotInTeam.OrbitNodeKey,
}, http.StatusForbidden)
// create an orbit host, assign to team
hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds)
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&createTeamResp.Team.ID, []uint{hostInTeam.ID})))
// Create a software installation request
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted)
// requesting download with alt != media fails
r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusBadRequest)
errMsg := extractServerErrorText(r.Body)
require.Contains(t, errMsg, "only alt=media is supported")
// valid download
r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusOK)
checkDownloadResponse(t, r, payload.Filename)
// Get execution ID, normally comes from orbit config
installUUID := getLatestSoftwareInstallExecID(t, s.ds, hostInTeam.ID)
// Installation complete, host no longer has access to software
s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{
HostID: hostInTeam.ID,
InstallUUID: installUUID,
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("done"),
},
}, http.StatusNoContent)
_ = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusForbidden)
// delete the installer
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID))
// check activity
s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
// download the installer, not found anymore
s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", *payload.TeamID))
})
t.Run("create team 0 software installer", func(t *testing.T) {
payload := &fleet.UploadSoftwareInstallerPayload{
TeamID: ptr.Uint(0),
InstallScript: "another install script",
PreInstallQuery: "another pre install query",
PostInstallScript: "another post install script",
Filename: "ruby.deb",
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
Title: "ruby",
Version: "1:2.5.1",
Source: "deb_packages",
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
SelfService: true,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// check the software installer
installerID, titleID := checkSoftwareInstaller(t, payload)
// check activity
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(),
fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true, "software_title_id": %d}`, titleID), 0)
// upload again fails
s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// download the installer
r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0))
checkDownloadResponse(t, r, payload.Filename)
// create an orbit host that is not in the team
hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds)
// downloading installer fails because there's no install request
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostNotInTeam.OrbitNodeKey,
}, http.StatusForbidden)
// create an orbit host, assign to team
hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds)
// requesting download with alt != media fails
r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusBadRequest)
errMsg := extractServerErrorText(r.Body)
require.Contains(t, errMsg, "only alt=media is supported")
// Create a software installation request
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted)
// valid download
r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusOK)
checkDownloadResponse(t, r, payload.Filename)
// Get execution ID, normally comes from orbit config
installUUID := getLatestSoftwareInstallExecID(t, s.ds, hostInTeam.ID)
// Installation complete, host no longer has access to software
s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{
HostID: hostInTeam.ID,
InstallUUID: installUUID,
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("done"),
},
}, http.StatusNoContent)
_ = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusForbidden)
_, err := s.ds.CreateOrUpdateSoftwareTitleIcon(context.Background(), &fleet.UploadSoftwareTitleIconPayload{
TitleID: titleID,
Filename: "icon.png",
TeamID: 0,
StorageID: "icon_storage_id",
})
require.NoError(t, err)
// delete the installer
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0")
var count int
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &count, `SELECT COUNT(1) FROM software_title_icons WHERE team_id = ? and software_title_id = ?`, 0, titleID)
})
require.Equal(t, 0, count)
// check activity
s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(),
fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": "/api/latest/fleet/software/titles/%d/icon?team_id=0", "team_name": null, "team_id": null, "self_service": true}`, titleID), 0)
// download the installer, not found anymore
s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0))
})
t.Run("uninstall migration for software installer", func(t *testing.T) {
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
Name: t.Name(),
}, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
payload := &fleet.UploadSoftwareInstallerPayload{
TeamID: &createTeamResp.Team.ID,
InstallScript: "another install script",
UninstallScript: "exit 1",
Filename: "ruby.deb",
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
Title: "ruby",
Version: "1:2.5.1",
Source: "deb_packages",
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
logger := kitlog.NewLogfmtLogger(os.Stderr)
// Run the migration when nothing is to be done
err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger)
require.NoError(t, err)
// check the software installer
installerID, titleID := checkSoftwareInstaller(t, payload)
var origPackageIDs string
var origExtension string
// Update DB by clearing package id and tweaking extension
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if err := sqlx.GetContext(context.Background(), q, &origPackageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`,
installerID); err != nil {
return err
}
require.NotEmpty(t, origPackageIDs)
if err := sqlx.GetContext(context.Background(), q, &origExtension, `SELECT extension FROM software_installers WHERE id = ?`,
installerID); err != nil {
return err
}
require.NotEmpty(t, origExtension)
if _, err = q.ExecContext(context.Background(), `UPDATE software_installers SET package_ids = '', extension = 'rb' WHERE id = ?`,
installerID); err != nil {
return err
}
return nil
})
// Check title to make it works without package id
respTitle := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", createTeamResp.Team.ID))
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript)
assert.Equal(t, "exit 1", respTitle.SoftwareTitle.SoftwarePackage.UninstallScript)
// Run the migration
err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger)
require.NoError(t, err)
// Check package ID and extension
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var packageIDs string
if err := sqlx.GetContext(context.Background(), q, &packageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`,
installerID); err != nil {
return err
}
assert.Equal(t, origPackageIDs, packageIDs)
var extension string
if err := sqlx.GetContext(context.Background(), q, &extension, `SELECT extension FROM software_installers WHERE id = ?`,
installerID); err != nil {
return err
}
assert.Equal(t, origExtension, extension)
return nil
})
// Check uninstall script
uninstallScript := file.GetUninstallScript("deb")
uninstallScript = strings.ReplaceAll(uninstallScript, "$PACKAGE_ID", "\"ruby\"")
respTitle = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", createTeamResp.Team.ID))
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript)
assert.Equal(t, uninstallScript, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript)
// Running the migration again causes no issues.
err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger)
require.NoError(t, err)
// Update DB by clearing package ids and swapping extension to one we skip
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if _, err = q.ExecContext(context.Background(), `UPDATE software_installers SET package_ids = '', extension = 'tar.gz' WHERE id = ?`,
installerID); err != nil {
return err
}
return nil
})
// Running the migration again causes no issues.
err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger)
require.NoError(t, err)
// Package ID and extension should not have been modified
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var packageIDs string
if err := sqlx.GetContext(context.Background(), q, &packageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`,
installerID); err != nil {
return err
}
assert.Empty(t, packageIDs)
var extension string
if err := sqlx.GetContext(context.Background(), q, &extension, `SELECT extension FROM software_installers WHERE id = ?`,
installerID); err != nil {
return err
}
assert.Equal(t, "tar.gz", extension)
return nil
})
// delete the installer
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent,
"team_id", fmt.Sprintf("%d", *payload.TeamID))
})
}
func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
// apply with software
// must not use applyTeamSpecsRequest and marshal it as JSON, as it will set
// all keys to their zerovalue, and some are only valid with mdm enabled.
teamSpecs := map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": map[string]any{
"packages": []map[string]any{
{
"url": "http://foo.com",
"self_service": true,
"install_script": map[string]string{
"path": "./foo/install-script.sh",
},
"post_install_script": map[string]string{
"path": "./foo/post-install-script.sh",
},
"pre_install_query": map[string]string{
"path": "./foo/query.yaml",
},
},
{
"url": "http://bar.com",
"install_script": map[string]string{
"path": "./bar/install-script.sh",
},
"post_install_script": map[string]string{
"path": "./bar/post-install-script.sh",
},
"pre_install_query": map[string]string{
"path": "./bar/query.yaml",
},
},
},
"app_store_apps": []map[string]any{
{
"app_store_id": "1234",
},
{
"app_store_id": "5678",
},
},
},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
wantSoftwarePackages := []fleet.SoftwarePackageSpec{
{
URL: "http://foo.com",
SelfService: true,
InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/install-script.sh"},
PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/post-install-script.sh"},
PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./foo/query.yaml"},
InstallDuringSetup: optjson.Bool{Set: true},
},
{
URL: "http://bar.com",
SelfService: false,
InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/install-script.sh"},
PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/post-install-script.sh"},
PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./bar/query.yaml"},
InstallDuringSetup: optjson.Bool{Set: true},
},
}
wantAppStoreApps := []fleet.TeamSpecAppStoreApp{
{
AppStoreID: "1234",
InstallDuringSetup: optjson.Bool{Set: true},
},
{
AppStoreID: "5678",
InstallDuringSetup: optjson.Bool{Set: true},
},
}
// retrieving the team returns the software
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value)
// apply without custom software specified, should not replace existing software
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value)
require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value)
// apply with explicitly empty custom software would clear the existing
// software, but dry-run
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": map[string]any{
"packages": nil,
},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value)
// apply with explicitly empty custom app store apps would clear the existing
// software, but dry-run
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": map[string]any{
"app_store_apps": nil,
},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value)
// apply with empty top-level software field, should not clear packages
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": nil,
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value)
require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value)
// apply with explicitly empty software packages clears the existing software, but not apps
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": map[string]any{
"packages": nil,
},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.Software.Packages.Value)
require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value)
// apply with explicitly empty software apps clears the existing apps
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": map[string]any{
"app_store_apps": nil,
},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.Software.Packages.Value)
require.Empty(t, teamResp.Team.Config.Software.AppStoreApps.Value)
// patch with an invalid array returns an error
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"software": []any{"foo", 1},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.Software.Packages.Value)
// patch with an invalid array returns an error
teamSpecs = map[string]any{
"specs": []any{
map[string]any{
"name": teamName,
"app_store_apps": []any{"foo", 1},
},
},
}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.Software.AppStoreApps.Value)
}
func (s *integrationEnterpriseTestSuite) TestSoftwareTitleIcons() {
t := s.T()
ctx := context.Background()
user, err := s.ds.UserByEmail(context.Background(), "admin1@example.com")
require.NoError(t, err)
host1 := test.NewHost(t, s.ds, "host1", "", "host1key", "host1uuid", time.Now())
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
// create software installer + software title
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "apps", BundleIdentifier: "foo.bundle.id"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host1.ID, software1)
require.NoError(t, err)
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
_, titleID, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "foo.pkg",
Title: "foo",
Version: "0.0.3",
Source: "apps",
TeamID: &tm.ID,
UserID: user.ID,
BundleIdentifier: "foo.bundle.id",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
parsePutResponse := func(resp *http.Response) putSoftwareTitleIconResponse {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
result := putSoftwareTitleIconResponse{}
err = json.Unmarshal(body, &result)
require.NoError(t, err)
return result
}
// get icon from file system
file, err := os.Open("testdata/icons/valid-icon.png")
require.NoError(t, err)
defer file.Close()
// get icon size for later
fileInfo, err := file.Stat()
require.NoError(t, err)
iconSize := fileInfo.Size()
// create proper form data to post
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
fileWriter, err := writer.CreateFormFile("icon", "icon.png")
require.NoError(t, err)
_, err = io.Copy(fileWriter, file)
require.NoError(t, err)
err = writer.Close()
require.NoError(t, err)
headers := map[string]string{
"Content-Type": writer.FormDataContentType(),
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
// get activities before the PUT request
activitiesBefore := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesBefore)
// PUT: create new software title icon
resp := s.DoRawWithHeaders(
"PUT",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", titleID, tm.ID),
buf.Bytes(),
http.StatusOK,
headers,
)
result := parsePutResponse(resp)
iconUrl := fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", titleID, tm.ID)
require.Nil(t, result.Err)
require.Contains(t, result.IconUrl, iconUrl)
// get activities after the PUT request
activitiesAfter := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesAfter)
// verify exactly 1 new edited_software activity was created
var editedSoftwareActivitiesBefore []fleet.Activity
var editedSoftwareActivitiesAfter []fleet.Activity
for _, activity := range activitiesBefore.Activities {
if activity.Type == "edited_software" {
editedSoftwareActivitiesBefore = append(editedSoftwareActivitiesBefore, *activity)
}
}
for _, activity := range activitiesAfter.Activities {
if activity.Type == "edited_software" {
editedSoftwareActivitiesAfter = append(editedSoftwareActivitiesAfter, *activity)
}
}
require.Len(t, editedSoftwareActivitiesAfter, len(editedSoftwareActivitiesBefore)+1, "Expected exactly 1 new edited_software activity")
// find the new activity by comparing before and after lists
var newActivity *fleet.Activity
for _, afterActivity := range editedSoftwareActivitiesAfter {
isNew := true
for _, beforeActivity := range editedSoftwareActivitiesBefore {
if afterActivity.ID == beforeActivity.ID {
isNew = false
break
}
}
if isNew {
newActivity = &afterActivity
break
}
}
require.NotNil(t, newActivity, "Should have found exactly one new activity")
// verify the new activity has the correct details
var details fleet.ActivityTypeEditedSoftware
err = json.Unmarshal(*newActivity.Details, &details)
require.NoError(t, err)
require.Equal(t, "foo", details.SoftwareTitle)
require.Equal(t, "foo.pkg", *details.SoftwarePackage)
require.Equal(t, iconUrl, *details.SoftwareIconURL)
require.Equal(t, titleID, details.SoftwareTitleID)
// PUT: Icon too large
// Create a fake large file (101KB of data)
largeData := make([]byte, 101*1024)
reader := bytes.NewReader(largeData)
iconFile, err := fleet.NewTempFileReader(reader, func() string { return t.TempDir() })
require.NoError(t, err)
var bufInvalid bytes.Buffer
writer = multipart.NewWriter(&bufInvalid)
fileWriter, err = writer.CreateFormFile("icon", "icon.png")
require.NoError(t, err)
_, err = io.Copy(fileWriter, iconFile)
require.NoError(t, err)
err = writer.Close()
require.NoError(t, err)
headers = map[string]string{
"Content-Type": writer.FormDataContentType(),
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
resp = s.DoRawWithHeaders(
"PUT",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", titleID, tm.ID),
bufInvalid.Bytes(),
http.StatusBadRequest,
headers,
)
var errorResp struct {
Message string `json:"message"`
Errors []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"errors"`
}
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
err = json.Unmarshal(body, &errorResp)
require.NoError(t, err)
assert.Equal(t, "Bad request", errorResp.Message)
require.Len(t, errorResp.Errors, 1)
assert.Contains(t, errorResp.Errors[0].Reason, "icon must be less than 100KB")
// PUT: gitops workflow, passing in sha256 & filename
var storedIcons []fleet.SoftwareTitleIcon
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
err := sqlx.SelectContext(ctx, q, &storedIcons, "SELECT storage_id, filename FROM software_title_icons")
if err != nil {
return err
}
return nil
})
require.Len(t, storedIcons, 1)
storedIcon := storedIcons[0]
var buf2 bytes.Buffer
writer = multipart.NewWriter(&buf2)
err = writer.WriteField("hash_sha256", storedIcon.StorageID)
require.NoError(t, err)
err = writer.WriteField("filename", storedIcon.Filename)
require.NoError(t, err)
err = writer.Close()
require.NoError(t, err)
headers = map[string]string{
"Content-Type": writer.FormDataContentType(),
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
resp = s.DoRawWithHeaders(
"PUT",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", titleID, tm.ID),
buf2.Bytes(),
http.StatusOK,
headers,
)
result = parsePutResponse(resp)
require.Nil(t, result.Err)
require.Contains(t, result.IconUrl, iconUrl)
// verify no new software title icons were created
storedIcons = make([]fleet.SoftwareTitleIcon, 0)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
err := sqlx.SelectContext(ctx, q, &storedIcons, "SELECT storage_id, filename FROM software_title_icons")
if err != nil {
return err
}
return nil
})
require.Len(t, storedIcons, 1)
require.Equal(t, storedIcon.StorageID, storedIcons[0].StorageID)
require.Equal(t, storedIcon.Filename, storedIcons[0].Filename)
// verify no new activity was created (should still be the same count as before)
activitiesAfterGitops := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesAfterGitops)
var editedSoftwareActivitiesAfterGitops []fleet.Activity
for _, activity := range activitiesAfterGitops.Activities {
if activity.Type == "edited_software" {
editedSoftwareActivitiesAfterGitops = append(editedSoftwareActivitiesAfterGitops, *activity)
}
}
require.Len(t, editedSoftwareActivitiesAfterGitops, len(editedSoftwareActivitiesAfter), "No new activity should be created for gitops upload")
// GET software title icon
headers = map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
resp = s.DoRawWithHeaders(
"GET",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", titleID, tm.ID),
nil,
http.StatusOK,
headers,
)
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
assert.Equal(t, fmt.Sprintf(`inline; filename="%s"`, "icon.png"), resp.Header.Get("Content-Disposition"))
assert.Equal(t, fmt.Sprintf("%d", iconSize), resp.Header.Get("Content-Length"))
// DELETE software title icon
s.DoRawWithHeaders(
"DELETE",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon?team_id=%d", titleID, tm.ID),
nil,
http.StatusOK,
headers,
)
require.NoError(t, err)
// verify new activity was created (should be one more than before)
activitiesAfterDelete := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesAfterDelete)
var editedSoftwareActivitiesAfterDelete []fleet.Activity
for _, activity := range activitiesAfterDelete.Activities {
if activity.Type == "edited_software" {
editedSoftwareActivitiesAfterDelete = append(editedSoftwareActivitiesAfterDelete, *activity)
}
}
require.Len(t, editedSoftwareActivitiesAfterDelete, len(editedSoftwareActivitiesAfter)+1, "Expected exactly 1 new activity after delete")
// ensure there is an activity where the icon url is set to empty
// signifying it's deleted
var deleteActivity *fleet.Activity
for _, afterActivity := range editedSoftwareActivitiesAfterDelete {
isNew := true
for _, beforeActivity := range editedSoftwareActivitiesAfter {
if afterActivity.ID == beforeActivity.ID {
isNew = false
break
}
}
if isNew {
deleteActivity = &afterActivity
break
}
}
require.NotNil(t, deleteActivity, "Should have found exactly one new activity from delete operation")
// verify the delete activity has the correct details
var deleteDetails fleet.ActivityTypeEditedSoftware
err = json.Unmarshal(*deleteActivity.Details, &deleteDetails)
require.NoError(t, err)
require.Equal(t, titleID, deleteDetails.SoftwareTitleID)
require.Equal(t, "foo", deleteDetails.SoftwareTitle)
require.Equal(t, "foo.pkg", *deleteDetails.SoftwarePackage)
require.Equal(t, "", *deleteDetails.SoftwareIconURL, "Icon URL should be empty after deletion")
_, err = s.softwareInstallStore.Cleanup(ctx, nil, time.Now())
require.NoError(t, err)
}
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
t := s.T()
ctx := context.Background()
// non-existent team
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo")
// create a team
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
// software with a bad URL
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{URL: "."},
}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
// software with a too big URL
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 4000-23)},
}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
// create an HTTP server to host the software installer
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ruby.deb" {
w.WriteHeader(http.StatusNotFound)
return
}
file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb"))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
_, err = io.Copy(w, file)
require.NoError(t, err)
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
// do a request with a URL that returns a 404.
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: srv.URL + "/not_found.pkg"},
}
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
message := waitBatchSetSoftwareInstallersFailed(t, s, tm.Name, batchResponse.RequestUUID)
require.NotEmpty(t, message)
require.Contains(t, message, fmt.Sprintf("validation failed: software.url Couldn't edit software. URL (\"%s/not_found.pkg\") returned \"Not Found\". Please make sure that URLs are reachable from your Fleet server.", srv.URL))
// do a request with a valid URL
rubyURL := srv.URL + "/ruby.deb"
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages := waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
var installerHash string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerHash, "SELECT storage_id FROM software_installers WHERE title_id = ?", packages[0].TitleID)
})
require.NotEmpty(t, installerHash)
softwareToInstallBadSecret := []*fleet.SoftwareInstallerPayload{
{
URL: rubyURL,
InstallScript: "echo $FLEET_SECRET_INVALID",
},
}
resp := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name)
errMsg := extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
resp = s.Do("POST", "/api/latest/fleet/software/batch?dry_run=true", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusAccepted, "team_name", tm.Name)
errMsg = extractServerErrorText(resp.Body)
require.Empty(t, errMsg)
softwareToInstallBadSecret[0].InstallScript = ""
softwareToInstallBadSecret[0].PostInstallScript = "echo $FLEET_SECRET_ALSO_INVALID"
resp = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name)
errMsg = extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_ALSO_INVALID")
resp = s.Do("POST", "/api/latest/fleet/software/batch?dry_run=true", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusAccepted, "team_name", tm.Name)
errMsg = extractServerErrorText(resp.Body)
require.Empty(t, errMsg)
softwareToInstallBadSecret[0].PostInstallScript = ""
softwareToInstallBadSecret[0].UninstallScript = "echo $FLEET_SECRET_THIRD_INVALID"
resp = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name)
errMsg = extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_THIRD_INVALID")
resp = s.Do("POST", "/api/latest/fleet/software/batch?dry_run=true", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusAccepted, "team_name", tm.Name)
errMsg = extractServerErrorText(resp.Body)
require.Empty(t, errMsg)
// TODO(roberto): test with a variety of response codes
// check the application status
titlesResp := listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(tm.ID))
require.Equal(t, 1, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 1)
// Check that the URL is set to software installers uploaded via batch.
require.NotNil(t, titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL)
require.Equal(t, rubyURL, *titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL)
// check that platform is set when the installer is created
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var platform string
if err := sqlx.GetContext(context.Background(), q, &platform, `SELECT platform FROM software_installers WHERE title_id= ? AND team_id = ?`, titlesResp.SoftwareTitles[0].ID, tm.ID); err != nil {
return err
}
require.Equal(t, "linux", platform)
return nil
})
// same payload doesn't modify anything
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
newTitlesResp := listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(tm.ID))
require.Equal(t, titlesResp, newTitlesResp)
// setting self-service to true updates the software title metadata
softwareToInstall[0].SelfService = true
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
newTitlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(tm.ID))
titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true)
require.Equal(t, titlesResp, newTitlesResp)
// empty payload cleans the software items
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Empty(t, packages)
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(tm.ID))
require.Equal(t, 0, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 0)
//////////////////////////
// Do a request with a valid URL with no team
//////////////////////////
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, SHA256: installerHash},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.Nil(t, packages[0].TeamID)
// check the application status on team 0
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
require.Equal(t, 1, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 1)
// same payload doesn't modify anything
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.Nil(t, packages[0].TeamID)
newTitlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
require.Equal(t, titlesResp, newTitlesResp)
// setting self-service to true updates the software title metadata
softwareToInstall[0].SelfService = true
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.Nil(t, packages[0].TeamID)
newTitlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true)
require.Equal(t, titlesResp, newTitlesResp)
// create some labels A and B
lblA, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "A"})
require.NoError(t, err)
lblB, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "B"})
require.NoError(t, err)
// providing both labels include/exclude results in an error
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}, LabelsExcludeAny: []string{lblB.Name}},
}
res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included.`)
// providing a non-existing label results in an error
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, LabelsIncludeAny: []string{"no-such-label"}},
}
res = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(res.Body), `some or all the labels provided don't exist`)
// valid installer scoped by label
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.Nil(t, packages[0].TeamID)
meta, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false)
require.NoError(t, err)
require.Empty(t, meta.LabelsExcludeAny)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, lblA.ID, meta.LabelsIncludeAny[0].LabelID)
require.Equal(t, lblA.Name, meta.LabelsIncludeAny[0].LabelName)
// empty payload cleans the software items
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Empty(t, packages)
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0)))
require.Equal(t, 0, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 0)
//////////////////////////
// Do a request with a fleet maintained app
//////////////////////////
oldTransport := http.DefaultTransport
onePassMockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("mocked content")); err != nil {
// Handle the error, e.g., log it or fail the test
t.Fatalf("failed to write response: %v", err)
}
}))
defer onePassMockServer.Close()
mockTransport := &mockRoundTripper{
mockServer: onePassMockServer.URL,
origBaseURL: "https://downloads.1password.com",
next: http.DefaultTransport,
}
http.DefaultTransport = mockTransport
// https://downloads.1password.com/mac/1Password-8.10.82-aarch64.zip
maintained1, err := s.ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "1Password",
Slug: "1password/darwin",
Platform: "darwin",
UniqueIdentifier: "com.1password.1password",
})
require.NoError(t, err)
// basic fleet maintained app with install script override
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{Slug: &maintained1.Slug, InstallScript: "echo 'Hello world'"},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].URL)
require.Nil(t, packages[0].TeamID)
var softwareTitleResponse getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *packages[0].TitleID), nil, http.StatusOK, &softwareTitleResponse, "teamId", "0")
require.Equal(t, "echo 'Hello world'", softwareTitleResponse.SoftwareTitle.SoftwarePackage.InstallScript)
require.Contains(t, softwareTitleResponse.SoftwareTitle.SoftwarePackage.UninstallScript, "LOGGED_IN_USER") // should pass through FMA script
// with a team
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
// with a label
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{Slug: &maintained1.Slug, LabelsIncludeAny: []string{lblA.Name}},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].URL)
require.Nil(t, packages[0].TeamID)
meta, err = s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false)
require.NoError(t, err)
require.Empty(t, meta.LabelsExcludeAny)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, lblA.ID, meta.LabelsIncludeAny[0].LabelID)
require.Equal(t, lblA.Name, meta.LabelsIncludeAny[0].LabelName)
// maintained app with no_check for sha, latest for version
maintained2, err := s.ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "Google Chrome",
Slug: "google-chrome/darwin",
Platform: "darwin",
UniqueIdentifier: "com.google.Chrome",
})
require.NoError(t, err)
chromeBytes, err := os.ReadFile(filepath.Join("testdata", "software-installers", "dummy_installer.pkg"))
require.NoError(t, err)
h := sha256.New()
_, err = h.Write(chromeBytes)
require.NoError(t, err)
chromeSHA := hex.EncodeToString(h.Sum(nil))
chromeMockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write(chromeBytes); err != nil {
// Handle the error, e.g., log it or fail the test
t.Fatalf("failed to write response: %v", err)
}
}))
defer chromeMockServer.Close()
mockTransport = &mockRoundTripper{
mockServer: chromeMockServer.URL,
origBaseURL: "https://dl.google.com",
next: http.DefaultTransport,
}
http.DefaultTransport = mockTransport
// Mock server to serve manifest with no_check/latest
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var versions []*ma.FMAManifestApp
versions = append(versions, &ma.FMAManifestApp{
Version: "latest",
Queries: ma.FMAQueries{
Exists: "SELECT 1 FROM osquery_info;",
},
InstallerURL: "https://dl.google.com/chrome.pkg",
InstallScriptRef: "foobaz",
UninstallScriptRef: "foobaz",
SHA256: "no_check",
})
manifest := ma.FMAManifestFile{
Versions: versions,
Refs: map[string]string{
"foobaz": "Hello World!",
},
}
err := json.NewEncoder(w).Encode(manifest)
require.NoError(t, err)
}))
defer manifestServer.Close()
mockTransport = &mockRoundTripper{
mockServer: manifestServer.URL,
origBaseURL: "https://raw.githubusercontent.com",
next: http.DefaultTransport,
}
http.DefaultTransport = mockTransport
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{Slug: &maintained2.Slug},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].URL)
// Given that the manifest returns "no_check" for Chrome hash, the SHA should be calculated from the downloaded file.
require.Equal(t, chromeSHA, packages[0].HashSHA256)
require.Nil(t, packages[0].TeamID)
// Given that the manifest returns "latest", the version should be parsed from the downloaded file.
titleResponse := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", *packages[0].TitleID), nil, http.StatusOK, &titleResponse, "team_id", "0")
require.Equal(t, "1.0.0", titleResponse.SoftwareTitle.SoftwarePackage.Version)
http.DefaultTransport = oldTransport
}
func waitBatchSetSoftwareInstallersCompleted(t *testing.T, s *integrationEnterpriseTestSuite, teamName string, requestUUID string) []fleet.SoftwarePackageResponse {
timeout := time.After(1 * time.Minute)
for {
var batchResultResponse batchSetSoftwareInstallersResultResponse
s.DoJSON("GET", "/api/latest/fleet/software/batch/"+requestUUID, nil, http.StatusOK, &batchResultResponse, "team_name", teamName)
if batchResultResponse.Status == fleet.BatchSetSoftwareInstallersStatusCompleted {
return batchResultResponse.Packages
}
select {
case <-timeout:
t.Fatalf("timeout: %s, %s", teamName, requestUUID)
case <-time.After(500 * time.Millisecond):
// OK, continue
}
}
}
func waitBatchSetSoftwareInstallersFailed(t *testing.T, s *integrationEnterpriseTestSuite, teamName string, requestUUID string) string {
timeout := time.After(1 * time.Minute)
for {
var batchResultResponse batchSetSoftwareInstallersResultResponse
s.DoJSON("GET", "/api/latest/fleet/software/batch/"+requestUUID, nil, http.StatusOK, &batchResultResponse, "team_name", teamName)
if batchResultResponse.Status == fleet.BatchSetSoftwareInstallersStatusFailed {
require.Empty(t, batchResultResponse.Packages)
return batchResultResponse.Message
}
select {
case <-timeout:
t.Fatalf("timeout: %s, %s", teamName, requestUUID)
case <-time.After(500 * time.Millisecond):
// OK, continue
}
}
}
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffects() {
t := s.T()
// create a team
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
// create an HTTP server to host the software installer
trailer := ""
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb"))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
_, err = io.Copy(w, file)
require.NoError(t, err)
_, err = w.Write([]byte(trailer))
require.NoError(t, err)
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
// set up software to install
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{URL: srv.URL},
}
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages := waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
require.Equal(t, srv.URL, packages[0].URL)
titlesResp := listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(tm.ID))
titleResponse := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse,
"team_id", fmt.Sprint(tm.ID))
uploadedAt := titleResponse.SoftwareTitle.SoftwarePackage.UploadedAt
// create a host that doesn't have fleetd installed
h, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + uuid.New().String()),
NodeKey: ptr.String(t.Name() + uuid.New().String()),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "ubuntu",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&tm.ID, []uint{h.ID}))
require.NoError(t, err)
h.TeamID = &tm.ID
// host installs fleetd
orbitKey := setOrbitEnrollment(t, h, s.ds)
h.OrbitNodeKey = &orbitKey
// create another host that doesn't have fleetd installed
h2, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + uuid.New().String()),
NodeKey: ptr.String(t.Name() + uuid.New().String()),
Hostname: fmt.Sprintf("%sbar.local", t.Name()),
Platform: "ubuntu",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&tm.ID, []uint{h2.ID}))
require.NoError(t, err)
h2.TeamID = &tm.ID
// host installs fleetd
orbitKey2 := setOrbitEnrollment(t, h2, s.ds)
h2.OrbitNodeKey = &orbitKey2
// install software
installResp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titlesResp.SoftwareTitles[0].ID), nil, http.StatusAccepted, &installResp)
// Get the install response, should be pending
getHostSoftwareResp := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
// Switch self-service flag
softwareToInstall[0].SelfService = true
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
require.Equal(t, srv.URL, packages[0].URL)
newTitlesResp := listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(tm.ID))
require.Equal(t, true, *newTitlesResp.SoftwareTitles[0].SoftwarePackage.SelfService)
// Install should still be pending
afterSelfServiceHostResp := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &afterSelfServiceHostResp)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
// update pre-install query
withUpdatedPreinstallQuery := []*fleet.SoftwareInstallerPayload{
{URL: srv.URL, PreInstallQuery: "SELECT * FROM os_version"},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedPreinstallQuery}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
require.Equal(t, srv.URL, packages[0].URL)
titleResponse = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse,
"team_id", fmt.Sprint(tm.ID))
require.Equal(t, "SELECT * FROM os_version", titleResponse.SoftwareTitle.SoftwarePackage.PreInstallQuery)
require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall)
// install should no longer be pending
afterPreinstallHostResp := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &afterPreinstallHostResp)
require.Nil(t, afterPreinstallHostResp.Software[0].Status)
// install software fully
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titlesResp.SoftwareTitles[0].ID), nil, http.StatusAccepted, &installResp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
installUUID := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *h.OrbitNodeKey, installUUID)), http.StatusNoContent)
// ensure install count is updated
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse,
"team_id", fmt.Sprint(tm.ID))
require.Equal(t, uint(1), titleResponse.SoftwareTitle.SoftwarePackage.Status.Installed)
require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall)
// install should show as complete
hostResp := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &hostResp)
require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status)
// update install script
withUpdatedInstallScript := []*fleet.SoftwareInstallerPayload{
{URL: srv.URL, InstallScript: "apt install ruby"},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedInstallScript}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
require.Equal(t, srv.URL, packages[0].URL)
// ensure install count is the same, and uploaded_at hasn't changed
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse,
"team_id", fmt.Sprint(tm.ID))
require.Equal(t, uint(1), titleResponse.SoftwareTitle.SoftwarePackage.Status.Installed)
require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall)
require.Equal(t, uploadedAt, titleResponse.SoftwareTitle.SoftwarePackage.UploadedAt)
// install should still show as complete
hostResp = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &hostResp)
require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status)
trailer = " " // add a character to the response for the installer HTTP call to ensure the file hashes differently
// update package
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedInstallScript}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
require.Equal(t, srv.URL, packages[0].URL)
// ensure install count is zeroed and uploaded_at HAS changed
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse,
"team_id", fmt.Sprint(tm.ID))
require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.Installed)
require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall)
require.NotEqual(t, uploadedAt, titleResponse.SoftwareTitle.SoftwarePackage.UploadedAt)
// install should be nulled out
hostResp = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &hostResp)
require.Nil(t, hostResp.Software[0].Status)
// install details record should still show as installed
installDetailsResp := getSoftwareInstallResultsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &installDetailsResp)
require.Equal(t, fleet.SoftwareInstalled, installDetailsResp.Results.Status)
// queue another install before we delete the installer via batch
pendingResp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titlesResp.SoftwareTitles[0].ID), nil, http.StatusAccepted, &pendingResp)
// install should show as pending
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &afterPreinstallHostResp)
require.Equal(t, fleet.SoftwareInstallPending, *afterPreinstallHostResp.Software[0].Status)
installUUID = afterPreinstallHostResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
// queue an uninstall on another host
uninstallResp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h2.ID, titlesResp.SoftwareTitles[0].ID), nil, http.StatusAccepted, &uninstallResp)
// uninstall should show as pending
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &afterPreinstallHostResp)
require.Equal(t, fleet.SoftwareUninstallPending, *afterPreinstallHostResp.Software[0].Status)
// delete all installers
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{}}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 0)
// software should no longer exist on either host
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &afterPreinstallHostResp)
require.Len(t, afterPreinstallHostResp.Software, 0)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &afterPreinstallHostResp)
require.Len(t, afterPreinstallHostResp.Software, 0)
// pending install record should not exist
installDetailsResp = getSoftwareInstallResultsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusNotFound, &installDetailsResp)
}
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPoliciesAssociated() {
ctx := context.Background()
t := s.T()
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
policy1Team1, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy1",
Query: "SELECT 1;",
},
)
require.NoError(t, err)
policy2Team2, err := s.ds.NewTeamPolicy(
ctx, team2.ID, nil, fleet.PolicyPayload{
Name: "team2Policy2",
Query: "SELECT 2;",
},
)
require.NoError(t, err)
// create an HTTP server to host software installers
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var fileName string
switch r.URL.Path {
case "/ruby.deb", "/dummy_installer.pkg":
fileName = strings.TrimPrefix(r.URL.Path, "/")
default:
w.WriteHeader(http.StatusNotFound)
return
}
file, err := os.Open(filepath.Join("testdata", "software-installers", fileName))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
_, err = io.Copy(w, file)
require.NoError(t, err)
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
// team1 has ruby.deb
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{
URL: srv.URL + "/ruby.deb",
},
}
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
packages := waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team1.ID, *packages[0].TeamID)
require.Equal(t, srv.URL+"/ruby.deb", packages[0].URL)
// team2 has dummy_installer.pkg and ruby.deb.
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{
URL: srv.URL + "/dummy_installer.pkg",
},
{
URL: srv.URL + "/ruby.deb",
},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
sort.Slice(packages, func(i, j int) bool {
return packages[i].URL < packages[j].URL
})
require.Len(t, packages, 2)
require.NotNil(t, packages[0].TitleID)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team2.ID, *packages[0].TeamID)
require.Equal(t, srv.URL+"/dummy_installer.pkg", packages[0].URL)
require.NotNil(t, packages[1].TitleID)
require.NotNil(t, packages[1].TeamID)
require.Equal(t, team2.ID, *packages[1].TeamID)
require.Equal(t, srv.URL+"/ruby.deb", packages[1].URL)
// Associate ruby.deb to policy1Team1.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
rubyDebTitleID := resp.SoftwareTitles[0].ID
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
// Associate ruby.deb in team2 to policy2Team2.
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy2Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
// Get rid of all installers in team1.
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 0)
// policy1Team1 should not be associated to any installer.
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Nil(t, policy1Team1.SoftwareInstallerID)
// team1 should be empty.
titlesResp := listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(team1.ID))
require.Equal(t, 0, titlesResp.Count)
// team2 should be untouched.
titlesResp = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id",
fmt.Sprint(team2.ID))
require.Equal(t, 2, titlesResp.Count)
require.Len(t, titlesResp.SoftwareTitles, 2)
require.NotNil(t, titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL)
require.Equal(t, srv.URL+"/dummy_installer.pkg", *titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL)
require.NotNil(t, titlesResp.SoftwareTitles[1].SoftwarePackage.PackageURL)
require.Equal(t, srv.URL+"/ruby.deb", *titlesResp.SoftwareTitles[1].SoftwarePackage.PackageURL)
// policy2Team2 should still be associated to ruby.deb of team2.
policy2Team2, err = s.ds.Policy(ctx, policy2Team2.ID)
require.NoError(t, err)
require.NotNil(t, policy2Team2.SoftwareInstallerID)
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() {
t := s.T()
hostsByPlatform := map[string]*fleet.Host{
"linux": nil, "darwin": nil, "windows": nil,
}
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
for platform := range hostsByPlatform {
h, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + uuid.New().String()),
NodeKey: ptr.String(t.Name() + uuid.New().String()),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: platform,
})
require.NoError(t, err)
setOrbitEnrollment(t, h, s.ds)
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&tm.ID, []uint{h.ID}))
require.NoError(t, err)
hostsByPlatform[platform] = h
}
softwareTitles := map[string]uint{
"deb": 0, "msi": 0, "exe": 0, "pkg": 0,
}
for kind := range softwareTitles {
// TODO(roberto): we need real binaries for exe, msi and pkg to
// perform the API calls.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
ctx := context.Background()
installScript := fmt.Sprintf(`echo '%s'`, kind)
res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, installScript, installScript)
if err != nil {
return err
}
scriptContentID, _ := res.LastInsertId()
uninstallScript := fmt.Sprintf(`echo uninstall '%s'`, kind)
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', ?)`, kind)
if err != nil {
return err
}
titleID, _ := res.LastInsertId()
softwareTitles[kind] = uint(titleID) //nolint:gosec // dismiss G115
platform := ""
switch kind {
case "deb":
platform = "linux"
case "msi", "exe":
platform = "windows"
case "pkg":
platform = "darwin"
}
_, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(title_id, filename, extension, version, platform, install_script_content_id, uninstall_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query, package_ids)
VALUES
(?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?, ?, ?)`,
titleID, fmt.Sprintf("installer.%s", kind), kind, "v1.0.0", platform, scriptContentID, uninstallScriptContentID,
hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo", "")
return err
})
}
testCases := []struct {
platform string
supportedInstallers []string
}{
{"windows", []string{"exe", "msi"}},
{"darwin", []string{"pkg"}},
{"linux", []string{"deb"}},
}
for _, tc := range testCases {
for platform, host := range hostsByPlatform {
for _, kind := range tc.supportedInstallers {
wantStatus := http.StatusAccepted
if tc.platform != platform {
wantStatus = http.StatusBadRequest
}
var resp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, softwareTitles[kind]), nil,
wantStatus, &resp)
}
}
}
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
t := s.T()
// Enabling software inventory globally, which will be inherited by the team
appConf, err := s.ds.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.Features.EnableSoftwareInventory = true
appConf.ServerSettings.ScriptsDisabled = true // shouldn't stop installs/uninstalls
err = s.ds.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
time.Sleep(2 * time.Second) // Wait for the app config cache to clear
t.Cleanup(func() {
acr := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {
"scripts_disabled": false
}
}`), http.StatusOK, &acr)
})
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
Name: t.Name(),
}, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
teamID := &createTeamResp.Team.ID
var resp installSoftwareResponse
// non-existent host
s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/1/install", nil, http.StatusNotFound, &resp)
s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/1/uninstall", nil, http.StatusNotFound, &resp)
// create a host that doesn't have fleetd installed
h, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + uuid.New().String()),
NodeKey: ptr.String(t.Name() + uuid.New().String()),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "ubuntu",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(teamID, []uint{h.ID}))
require.NoError(t, err)
h.TeamID = teamID
// request fails
resp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/install", h.ID), nil, http.StatusUnprocessableEntity, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/uninstall", h.ID), nil, http.StatusUnprocessableEntity, &resp)
// host installs fleetd
orbitKey := setOrbitEnrollment(t, h, s.ds)
h.OrbitNodeKey = &orbitKey
// request fails because of non-existent title
resp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/install", h.ID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/uninstall", h.ID), nil, http.StatusBadRequest, &resp)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "another install script",
PreInstallQuery: "another pre install query",
PostInstallScript: "another post install script",
UninstallScript: "another uninstall script with $PACKAGE_ID",
Filename: "ruby.deb",
Title: "ruby",
TeamID: teamID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
// Get title with software installer
respTitle := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", *teamID))
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript)
assert.Equal(t, `another uninstall script with "ruby"`, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript)
// Upload another package for another platform
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
Filename: "dummy_installer.pkg",
Title: "DummyApp",
TeamID: teamID,
}
s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
pkgTitleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", pkgTitleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", *teamID))
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
assert.NotEmpty(t, respTitle.SoftwareTitle.SoftwarePackage.InstallScript)
assert.NotEmpty(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript)
assert.NotContains(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript, "$PACKAGE_ID")
assert.Contains(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript, "com.example.dummy")
// install/uninstall request fails for the wrong platform
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, pkgTitleID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, pkgTitleID), nil, http.StatusBadRequest, &resp)
// delete software installer which we will not use
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", pkgTitleID), nil, http.StatusNoContent,
"team_id", fmt.Sprintf("%d", *teamID))
// install software request succeeds
resp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusAccepted, &resp)
// Get the results, should be pending
getHostSoftwareResp := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
require.NotNil(t, getHostSoftwareResp.Software[0].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
assert.Nil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
installUUID := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
gsirr := getSoftwareInstallResultsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &gsirr)
require.NoError(t, gsirr.Err)
require.NotNil(t, gsirr.Results)
results := gsirr.Results
require.Equal(t, installUUID, results.InstallUUID)
require.Equal(t, fleet.SoftwareInstallPending, results.Status)
// Can't install/uninstall if software install is pending
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusBadRequest, &resp)
// create 3 more hosts, will have statuses installed, failed and one with two
// install requests - one failed and the latest install pending
h2 := createOrbitEnrolledHost(t, "linuxmint", "host2", s.ds)
h3 := createOrbitEnrolledHost(t, "debian", "host3", s.ds)
h4 := createOrbitEnrolledHost(t, "tuxedo", "host4", s.ds)
err = s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(teamID, []uint{h2.ID, h3.ID, h4.ID}))
require.NoError(t, err)
// cancel pending refetches so we can see when refetches are queued
require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), h2.ID, false))
require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), h3.ID, false))
require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), h4.ID, false))
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h2.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID2 := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *h2.OrbitNodeKey, installUUID2)), http.StatusNoContent)
// Verify refetch requested is set after successful install
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h2.ID), nil, http.StatusOK, &hostResp)
require.True(t, hostResp.Host.RefetchRequested, "RefetchRequested should be true after successful software install")
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h3.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h3.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID3 := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 1,
"install_script_output": "failed"
}`, *h3.OrbitNodeKey, installUUID3)), http.StatusNoContent)
// Verify refetch requested is NOT set after failed install
var hostRespFailed getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h3.ID), nil, http.StatusOK, &hostRespFailed)
require.False(t, hostRespFailed.Host.RefetchRequested, "RefetchRequested should be false after failed software install")
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h4.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h4.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID4a := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": ""
}`, *h4.OrbitNodeKey, installUUID4a)), http.StatusNoContent)
// Verify refetch requested is NOT set after failed pre-install condition
var hostRespPreInstallFailed getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h4.ID), nil, http.StatusOK, &hostRespPreInstallFailed)
require.False(t, hostRespPreInstallFailed.Host.RefetchRequested, "RefetchRequested should be false after failed pre-install condition")
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h4.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h4.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID4b := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
_ = installUUID4b
// status is reflected in software title response
titleResp := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id",
fmt.Sprint(*teamID))
// TODO: confirm expected behavior of the title response host counts (unspecified)
require.Zero(t, titleResp.SoftwareTitle.HostsCount)
require.Nil(t, titleResp.SoftwareTitle.CountsUpdatedAt)
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage)
require.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name)
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status)
require.Equal(t, fleet.SoftwareInstallerStatusSummary{
Installed: 1,
PendingInstall: 2,
FailedInstall: 1,
}, *titleResp.SoftwareTitle.SoftwarePackage.Status)
// status is reflected in list hosts responses and counts when filtering by software title and status
// create a label to test also the counts per label with the software install status filter
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "test",
Hosts: []string{h.Hostname, h2.Hostname, h3.Hostname, h4.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
cases := []struct {
status string
count int
hostIDs []uint
}{
{"pending", 2, []uint{h.ID, h4.ID}},
{"failed", 1, []uint{h3.ID}},
{"installed", 1, []uint{h2.ID}},
}
for _, c := range cases {
t.Run(c.status, func(t *testing.T) {
var listResp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", c.status, "team_id",
fmt.Sprint(*teamID), "software_title_id", fmt.Sprint(titleID))
require.Len(t, listResp.Hosts, c.count)
gotIDs := make([]uint, 0, c.count)
for _, h := range listResp.Hosts {
gotIDs = append(gotIDs, h.ID)
}
require.ElementsMatch(t, c.hostIDs, gotIDs)
var countResp countHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", c.status, "team_id",
fmt.Sprint(*teamID), "software_title_id", fmt.Sprint(titleID))
require.Equal(t, c.count, countResp.Count)
// count with label filter
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", c.status, "team_id",
fmt.Sprint(*teamID), "software_title_id", fmt.Sprint(titleID), "label_id", fmt.Sprint(labelResp.Label.ID))
require.Equal(t, c.count, countResp.Count)
listResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp,
"software_status", c.status, "team_id", fmt.Sprint(*teamID), "software_title_id", fmt.Sprint(titleID))
require.Len(t, listResp.Hosts, c.count)
gotIDs = make([]uint, 0, c.count)
for _, h := range listResp.Hosts {
gotIDs = append(gotIDs, h.ID)
}
require.ElementsMatch(t, c.hostIDs, gotIDs)
})
}
// filter validations
r := s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "uninstalled")
require.Contains(t, extractServerErrorText(r.Body), "Invalid software_status")
r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed")
require.Contains(t, extractServerErrorText(r.Body), "Missing software_title_id")
r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "software_title_id", "1")
require.Contains(t, extractServerErrorText(r.Body), "Missing team_id")
r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1")
require.Contains(t, extractServerErrorText(r.Body), "Missing software_title_id")
r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_version_id", "1")
require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_version_id and software_title_id is not allowed.")
r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_id", "1")
require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.")
// Test: software_title_id DNE and software_status is valid (should return 0 hosts, 0 count)
nonExistentTitleID := uint(999999) // unlikely to exist
validStatuses := []string{"installed", "pending", "failed"}
for _, status := range validStatuses {
t.Run("nonexistent_title_id_"+status, func(t *testing.T) {
// List hosts
var listResp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp,
"software_status", status,
"team_id", fmt.Sprint(*teamID),
"software_title_id", fmt.Sprint(nonExistentTitleID),
)
require.Empty(t, listResp.Hosts)
// Count hosts
var countResp countHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp,
"software_status", status,
"team_id", fmt.Sprint(*teamID),
"software_title_id", fmt.Sprint(nonExistentTitleID),
)
require.Equal(t, 0, countResp.Count)
})
}
// Return installed app with software detail query
distributedReq := submitDistributedQueryResultsRequestShim{
NodeKey: *h2.NodeKey,
Results: map[string]json.RawMessage{
hostDetailQueryPrefix + "software_linux": json.RawMessage(fmt.Sprintf(
`[{"name": "%s", "version": "1.0", "type": "Package (deb)",
"source": "deb_packages", "last_opened_at": "",
"installed_path": "/bin/ruby"}]`, payload.Title)),
},
Statuses: map[string]interface{}{
hostDistributedQueryPrefix + "software_linux": 0,
},
Messages: map[string]string{},
Stats: map[string]*fleet.Stats{},
}
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].Status)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.NotEmpty(t, getHostSoftwareResp.Software[0].InstalledVersions, "Installed versions should exist")
// Remove the installed app of h2 by not returning it
distributedReq = submitDistributedQueryResultsRequestShim{
NodeKey: *h2.NodeKey,
Results: map[string]json.RawMessage{
hostDetailQueryPrefix + "software_linux": json.RawMessage(`[]`),
},
Statuses: map[string]interface{}{
hostDistributedQueryPrefix + "software_linux": 0,
},
Messages: map[string]string{},
Stats: map[string]*fleet.Stats{},
}
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.Nil(t, getHostSoftwareResp.Software[0].Status)
assert.Nil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Empty(t, getHostSoftwareResp.Software[0].InstalledVersions, "Installed versions should now not exist")
// Mark original h install successful
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *h.OrbitNodeKey, installUUID)), http.StatusNoContent)
// simulate a lock/unlock on h; this creates the host_mdm_actions table, which reproduces #25144
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", h.ID), nil, http.StatusOK)
status, err := s.ds.GetHostLockWipeStatus(context.Background(), h)
require.NoError(t, err)
var orbitScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey, status.LockScript.ExecutionID)),
http.StatusOK, &orbitScriptResp)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", h.ID), nil, http.StatusOK)
status, err = s.ds.GetHostLockWipeStatus(context.Background(), h)
require.NoError(t, err)
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey, status.UnlockScript.ExecutionID)),
http.StatusOK, &orbitScriptResp)
token := "secret_token"
createDeviceTokenForHost(t, s.ds, h.ID, token)
// Remove pending refresh so we can see if uninstall sets it
require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), h.ID, false))
// Do uninstall on h
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
uninstallExecutionID := getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID
// Uninstall should show up as a pending activity
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", h.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), listUpcomingAct.Activities[0].Type)
details := make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*listUpcomingAct.Activities[0].Details, &details))
assert.EqualValues(t, fleet.SoftwareUninstallPending, details["status"])
// should be visible from My device
s.DoRawNoAuth("GET", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%s/results", token, uninstallExecutionID), nil, http.StatusOK)
// Check that status is reflected in software title response
titleResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id",
fmt.Sprint(*teamID))
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage)
assert.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name)
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status)
assert.Equal(t, fleet.SoftwareInstallerStatusSummary{
PendingInstall: 1,
FailedInstall: 1,
PendingUninstall: 1,
}, *titleResp.SoftwareTitle.SoftwarePackage.Status)
// Another install/uninstall cannot be sent once an uninstall is pending
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusBadRequest, &resp)
// expect uninstall script (uninstall software works via a script) to be pending
var orbitResp orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)),
http.StatusOK, &orbitResp)
require.Len(t, orbitResp.Notifications.PendingScriptExecutionIDs, 1)
require.Equal(t, uninstallExecutionID, orbitResp.Notifications.PendingScriptExecutionIDs[0])
// Refetch should not be requested yet as the uninstall is still pending
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp)
require.False(t, hostResp.Host.RefetchRequested, "RefetchRequested should not be true yet")
// Host sends successful uninstall result
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Verify refetch requested is set after successful uninstall
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp)
require.True(t, hostResp.Host.RefetchRequested, "RefetchRequested should be true after successful software uninstall")
// Check activity feed
var activitiesResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", h.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id",
"order_direction", "desc")
require.NotEmpty(t, activitiesResp.Activities)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type)
details = make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details))
assert.Equal(t, "uninstalled", details["status"])
// should be visible from My device
s.DoRawNoAuth("GET", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%s/results", token, uninstallExecutionID), nil, http.StatusOK)
// Software should be available for install again
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
assert.Nil(t, getHostSoftwareResp.Software[0].Status)
// Remove pending refresh so we can see if failed uninstall sets it
require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), h.ID, false))
// Uninstall again, but this time with a failed result
beforeUninstall := time.Now()
// Since host_script_results does not use fine-grained timestamps yet, we adjust
beforeUninstall = beforeUninstall.Add(-time.Second)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
uninstallExecutionID = getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID
// Host sends failed uninstall result
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 1, "output": "not ok"}`, *h.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Verify refetch requested is NOT set after failed uninstall
var hostRespFailedUninstall getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostRespFailedUninstall)
require.False(t, hostRespFailedUninstall.Host.RefetchRequested, "RefetchRequested should be false after failed software uninstall")
// Check activity feed
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", h.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id",
"order_direction", "desc")
require.NotEmpty(t, activitiesResp.Activities)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type)
details = make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details))
assert.Equal(t, "failed", details["status"])
// Access software install/uninstall result after host is deleted
err = s.ds.DeleteHost(context.Background(), h.ID)
require.NoError(t, err)
instResult, err := s.ds.GetSoftwareInstallResults(context.Background(), installUUID)
require.NoError(t, err)
require.NotNil(t, instResult.HostDeletedAt)
gsirr = getSoftwareInstallResultsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &gsirr)
require.NoError(t, gsirr.Err)
require.NotNil(t, gsirr.Results)
results = gsirr.Results
require.Equal(t, installUUID, results.InstallUUID)
require.Equal(t, fleet.SoftwareInstalled, results.Status)
var scriptResultResp getScriptResultResponse
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+uninstallExecutionID, nil, http.StatusOK, &scriptResultResp)
assert.Equal(t, h.ID, scriptResultResp.HostID)
assert.NotEmpty(t, scriptResultResp.ScriptContents)
require.NotNil(t, scriptResultResp.ExitCode)
assert.EqualValues(t, 1, *scriptResultResp.ExitCode)
assert.Equal(t, "not ok", scriptResultResp.Output)
assert.Less(t, beforeUninstall, scriptResultResp.CreatedAt)
appConf.ServerSettings.ScriptsDisabled = false // set back to normal
err = s.ds.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
}
func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstallUninstall() {
t := s.T()
host1 := createOrbitEnrolledHost(t, "ubuntu", "", s.ds)
token := "secret_token"
createDeviceTokenForHost(t, s.ds, host1.ID, token)
host2 := createOrbitEnrolledHost(t, "ubuntu", "2", s.ds)
token2 := "secret_token2"
createDeviceTokenForHost(t, s.ds, host2.ID, token2)
// Create a label and assign it to the host
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: t.Name(),
Query: "select 1",
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
err := s.ds.RecordLabelQueryExecutions(context.Background(), host1, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
payloadNoSS := &fleet.UploadSoftwareInstallerPayload{
PreInstallQuery: "SELECT 1",
InstallScript: "install",
PostInstallScript: "echo hi",
Filename: "ruby.deb",
Title: "ruby",
SelfService: false,
}
s.uploadSoftwareInstaller(t, payloadNoSS, http.StatusOK, "")
titleIDNoSS := getSoftwareTitleID(t, s.ds, payloadNoSS.Title, "deb_packages")
payloadSS := &fleet.UploadSoftwareInstallerPayload{
PreInstallQuery: "SELECT 2",
InstallScript: "install again",
PostInstallScript: "echo bye",
Filename: "emacs.deb",
Title: "emacs",
SelfService: true,
LabelsIncludeAny: []string{labelResp.Label.Name},
}
s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "")
titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
// cannot self-install if software installer does not allow it
res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDNoSS), nil, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available through self-service")
// Add an installer with an exclude any label. Installation attempt should fail.
payloadLabelSS := &fleet.UploadSoftwareInstallerPayload{
PreInstallQuery: "SELECT 42",
InstallScript: "install again",
PostInstallScript: "echo bye",
Filename: "vim.deb",
Title: "vim",
SelfService: true,
LabelsExcludeAny: []string{labelResp.Label.Name},
}
s.uploadSoftwareInstaller(t, payloadLabelSS, http.StatusOK, "")
titleIDLabelSS := getSoftwareTitleID(t, s.ds, payloadLabelSS.Title, "deb_packages")
resp := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDLabelSS), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.")
// request self-install of software that allows it (is self-service + label scoped)
s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDSS), nil, http.StatusAccepted)
// it shows up as "self-installed" in the upcoming activities of the host
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
require.Nil(t, listUpcomingAct.Activities[0].ActorID)
require.False(t, listUpcomingAct.Activities[0].FleetInitiated)
var details fleet.ActivityTypeInstalledSoftware
err = json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details)
require.NoError(t, err)
require.Equal(t, host1.ID, details.HostID)
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
require.True(t, details.SelfService)
require.EqualValues(t, fleet.SoftwareInstallPending, details.Status)
installID := details.InstallUUID
// record the installation results
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "1",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *host1.OrbitNodeKey, installID)),
http.StatusNoContent)
// nothing in upcoming activities anymore
listUpcomingAct = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 0)
// installation shows up in past activities
var listPastAct listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host1.ID), nil, http.StatusOK, &listPastAct)
require.Len(t, listPastAct.Activities, 1)
require.Nil(t, listPastAct.Activities[0].ActorID)
require.False(t, listPastAct.Activities[0].FleetInitiated)
err = json.Unmarshal([]byte(*listPastAct.Activities[0].Details), &details)
require.NoError(t, err)
require.Equal(t, host1.ID, details.HostID)
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
require.True(t, details.SelfService)
require.EqualValues(t, fleet.SoftwareInstalled, details.Status)
// Do uninstall on host
s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%d", token, titleIDSS), nil, http.StatusAccepted)
var getHostSoftwareResp getHostSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 2)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
uninstallExecutionID := getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID
// Uninstall should show up as a pending activity
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), listUpcomingAct.Activities[0].Type)
uninstallDetails := make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*listUpcomingAct.Activities[0].Details, &uninstallDetails))
assert.EqualValues(t, fleet.SoftwareUninstallPending, uninstallDetails["status"])
assert.EqualValues(t, true, uninstallDetails["self_service"])
assert.Nil(t, listUpcomingAct.Activities[0].ActorID)
assert.False(t, listUpcomingAct.Activities[0].FleetInitiated)
// Check uninstall results via device endpoint before execution
res = s.DoRawNoAuth("GET", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%s/results", token, uninstallExecutionID), nil, http.StatusOK)
uninstallResult := getScriptResultResponse{}
err = json.NewDecoder(res.Body).Decode(&uninstallResult)
require.NoError(t, err)
require.Equal(t, host1.DisplayName(), uninstallResult.HostName)
require.Equal(t, uninstallExecutionID, uninstallResult.ExecutionID)
require.Nil(t, uninstallResult.ExitCode)
require.Equal(t, "", uninstallResult.Output)
// Host sends successful uninstall result
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host1.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Check activity feed
var activitiesResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host1.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id",
"order_direction", "desc")
require.NotEmpty(t, activitiesResp.Activities)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type)
uninstallDetails = make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &uninstallDetails))
assert.Equal(t, "uninstalled", uninstallDetails["status"])
assert.EqualValues(t, true, uninstallDetails["self_service"])
// Check uninstall results via device endpoint after execution
res = s.DoRawNoAuth("GET", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%s/results", token, uninstallExecutionID), nil, http.StatusOK)
uninstallResult = getScriptResultResponse{}
err = json.NewDecoder(res.Body).Decode(&uninstallResult)
require.NoError(t, err)
require.Equal(t, host1.DisplayName(), uninstallResult.HostName)
require.Equal(t, uninstallExecutionID, uninstallResult.ExecutionID)
require.Zero(t, *uninstallResult.ExitCode)
require.Equal(t, "ok", uninstallResult.Output)
// make sure uninstall endpoint errors properly
s.DoRawNoAuth("GET", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%s/results", token2, uninstallExecutionID), nil, http.StatusNotFound)
s.DoRawNoAuth("GET", fmt.Sprintf("/api/v1/fleet/device/%s/software/uninstall/%s/results", token, uninstallExecutionID+`f`), nil, http.StatusNotFound)
}
func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
ctx := context.Background()
t := s.T()
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
// Create software installers and corresponding host install requests.
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
PostInstallScript: "post install script",
Filename: "ruby.deb",
Title: "ruby",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
payload2 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script 2",
PreInstallQuery: "pre install query 2",
PostInstallScript: "post install script 2",
Filename: "vim.deb",
Title: "vim",
}
s.uploadSoftwareInstaller(t, payload2, http.StatusOK, "")
titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages")
payload3 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script 3",
PreInstallQuery: "pre install query 3",
PostInstallScript: "post install script 3",
Filename: "emacs.deb",
Title: "emacs",
}
s.uploadSoftwareInstaller(t, payload3, http.StatusOK, "")
titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages")
latestInstallUUID := func() string {
var id string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM upcoming_activities ORDER BY id DESC LIMIT 1`)
})
return id
}
// create some install requests for the host
beforeInstall := time.Now()
installUUIDs := make([]string, 3)
titleIDs := []uint{titleID, titleID2, titleID3}
for i := 0; i < len(installUUIDs); i++ {
resp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp)
installUUIDs[i] = latestInstallUUID()
}
type result struct {
HostID uint
InstallUUID string
Status fleet.SoftwareInstallerStatus
Output *string
PostInstallScriptOutput *string
PreInstallQueryOutput *string
}
checkResults := func(want result) {
var resp getSoftwareInstallResultsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/install/%s/results", want.InstallUUID), nil, http.StatusOK, &resp)
assert.Equal(t, want.HostID, resp.Results.HostID)
assert.Equal(t, want.InstallUUID, resp.Results.InstallUUID)
assert.Equal(t, want.Status, resp.Results.Status)
assert.Equal(t, want.PreInstallQueryOutput, resp.Results.PreInstallQueryOutput)
assert.Equal(t, want.Output, resp.Results.Output)
assert.Equal(t, want.PostInstallScriptOutput, resp.Results.PostInstallScriptOutput)
assert.Less(t, beforeInstall, resp.Results.CreatedAt)
assert.Greater(t, time.Now(), resp.Results.CreatedAt)
}
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "1",
"install_script_exit_code": 1,
"install_script_output": "failed"
}`, *host.OrbitNodeKey, installUUIDs[0])),
http.StatusNoContent)
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[0],
Status: fleet.SoftwareInstallFailed,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQuerySuccessCopy),
Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallFailCopy, "failed")),
})
wantAct := fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
InstallUUID: installUUIDs[0],
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": ""
}`, *host.OrbitNodeKey, installUUIDs[1])),
http.StatusNoContent)
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[1],
Status: fleet.SoftwareInstallFailed,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQueryFailCopy),
})
wantAct = fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload2.Title,
SoftwarePackage: payload2.Filename,
InstallUUID: installUUIDs[1],
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "1",
"install_script_exit_code": 0,
"install_script_output": "success",
"post_install_script_exit_code": 0,
"post_install_script_output": "ok"
}`, *host.OrbitNodeKey, installUUIDs[2])),
http.StatusNoContent)
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[2],
Status: fleet.SoftwareInstalled,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQuerySuccessCopy),
Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallSuccessCopy, "success")),
PostInstallScriptOutput: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerPostInstallSuccessCopy, "ok")),
})
wantAct = fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload3.Title,
SoftwarePackage: payload3.Filename,
InstallUUID: installUUIDs[2],
Status: string(fleet.SoftwareInstalled),
}
lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
// non-existing installation uuid
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": "uuid-no-such",
"pre_install_condition_output": ""
}`, *host.OrbitNodeKey)),
http.StatusNotFound)
// no new activity created
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), lastActID)
// "re-install", but this time there's an installer download failure
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": %d,
"install_script_output": "Installer download failed"
}`, *host.OrbitNodeKey, installUUIDs[2], fleet.ExitCodeInstallerDownloadFailed)),
http.StatusNoContent)
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[2],
Status: fleet.SoftwareInstallFailed,
Output: ptr.String(fleet.SoftwareInstallerDownloadFailedCopy),
})
wantAct = fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload3.Title,
SoftwarePackage: payload3.Filename,
InstallUUID: installUUIDs[2],
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
var hostActivityResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &hostActivityResp)
require.Len(t, hostActivityResp.Activities, 4)
require.NotNil(t, hostActivityResp.Activities[0].Details)
var actDetails fleet.ActivityTypeInstalledSoftware
require.NoError(t, json.Unmarshal(*hostActivityResp.Activities[0].Details, &actDetails))
require.Equal(t, wantAct, actDetails)
}
func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
t := s.T()
ctx := context.Background()
// create a host and request a script execution
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "host_soft_delete_team"})
require.NoError(t, err)
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
// create an anonymous script execution request
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp)
scriptExecID := runResp.ExecutionID
// post a script result so that the (past) activity is created
s.Do("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, scriptExecID)),
http.StatusOK)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(
`{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null}`,
host.ID, host.DisplayName(), scriptExecID), 0)
// create a saved script execution request
var newScriptResp createScriptResponse
body, headers := generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprint(tm.ID)}})
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err)
require.NotZero(t, newScriptResp.ScriptID)
savedScriptID := newScriptResp.ScriptID
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedScriptID}, http.StatusAccepted, &runResp)
savedScriptExecID := runResp.ExecutionID
// post a script result so that the (past) activity is created
s.Do("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "saved"}`, *host.OrbitNodeKey, savedScriptExecID)),
http.StatusOK)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null}`,
host.ID, host.DisplayName(), savedScriptExecID), 0)
// get the anonymous script result details
var scriptRes getScriptResultResponse
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+scriptExecID, nil, http.StatusOK, &scriptRes)
require.Equal(t, scriptExecID, scriptRes.ExecutionID)
require.Equal(t, host.ID, scriptRes.HostID)
require.Equal(t, "ok", scriptRes.Output)
require.NotNil(t, scriptRes.ExitCode)
require.EqualValues(t, 0, *scriptRes.ExitCode)
// get the saved script result details
scriptRes = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+savedScriptExecID, nil, http.StatusOK, &scriptRes)
require.Equal(t, savedScriptExecID, scriptRes.ExecutionID)
require.Equal(t, host.ID, scriptRes.HostID)
require.Equal(t, "saved", scriptRes.Output)
require.NotNil(t, scriptRes.ExitCode)
require.EqualValues(t, 0, *scriptRes.ExitCode)
// delete the host
var deleteResp deleteHostResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &deleteResp)
// get the anonymous script result details, still works
scriptRes = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+scriptExecID, nil, http.StatusOK, &scriptRes)
require.Equal(t, scriptExecID, scriptRes.ExecutionID)
require.Equal(t, host.ID, scriptRes.HostID)
require.Equal(t, "ok", scriptRes.Output)
require.NotNil(t, scriptRes.ExitCode)
require.EqualValues(t, 0, *scriptRes.ExitCode)
// get the saved script result details, still works
scriptRes = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+savedScriptExecID, nil, http.StatusOK, &scriptRes)
require.Equal(t, savedScriptExecID, scriptRes.ExecutionID)
require.Equal(t, host.ID, scriptRes.HostID)
require.Equal(t, "saved", scriptRes.Output)
require.NotNil(t, scriptRes.ExitCode)
require.EqualValues(t, 0, *scriptRes.ExitCode)
// delete the named script
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", savedScriptID), nil, http.StatusNoContent)
// get the saved script result details, still works because the saved script
// is a "soft-reference", when deleted the results become essentially results
// for an anonymous script (i.e. the script_id FK is "ON DELETE SET NULL").
scriptRes = getScriptResultResponse{}
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+savedScriptExecID, nil, http.StatusOK, &scriptRes)
require.Equal(t, savedScriptExecID, scriptRes.ExecutionID)
require.Equal(t, host.ID, scriptRes.HostID)
require.Equal(t, "saved", scriptRes.Output)
require.NotNil(t, scriptRes.ExitCode)
require.EqualValues(t, 0, *scriptRes.ExitCode)
}
func getSoftwareTitleID(t *testing.T, ds *mysql.Datastore, title, source string) uint {
var id uint
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''`, title, source)
})
return id
}
func genDistributedReqWithEntraIDDetails(host *fleet.Host, deviceID, userPrincipalName string) submitDistributedQueryResultsRequestShim {
results := make(map[string]json.RawMessage)
results["fleet_detail_query_conditional_access_microsoft_device_id"] = json.RawMessage(fmt.Sprintf(`[{"device_id": "%s", "user_principal_name": "%s"}]`, deviceID, userPrincipalName))
return submitDistributedQueryResultsRequestShim{
NodeKey: *host.NodeKey,
Results: results,
Statuses: make(map[string]interface{}),
Messages: make(map[string]string),
Stats: map[string]*fleet.Stats{},
}
}
func triggerAndWait(ctx context.Context, t *testing.T, ds fleet.Datastore, s *schedule.Schedule, timeout time.Duration) {
// Following code assumes (for simplicity) only triggered runs.
stats, err := ds.GetLatestCronStats(ctx, s.Name())
require.NoError(t, err)
var previousRunID int
if len(stats) > 0 {
previousRunID = stats[0].ID
}
_, err = s.Trigger()
require.NoError(t, err)
timeoutCh := time.After(timeout)
for {
stats, err := ds.GetLatestCronStats(ctx, s.Name())
require.NoError(t, err)
if len(stats) > 0 && stats[0].ID > previousRunID && stats[0].Status == fleet.CronStatsStatusCompleted {
t.Logf("cron %s:%d done", s.Name(), stats[0].ID)
return
}
select {
case <-timeoutCh:
t.Logf("timeout waiting for schedule %s to complete", s.Name())
t.Fail()
case <-time.After(250 * time.Millisecond):
}
}
}
func (s *integrationEnterpriseTestSuite) cleanupQuery(queryID uint) {
var delResp deleteQueryByIDResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/id/%d", queryID), nil, http.StatusOK, &delResp)
}
func (s *integrationEnterpriseTestSuite) TestAutofillPoliciesAuthTeamUser() {
t := s.T()
startMockServer := func(t *testing.T) string {
// create a test http server
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
switch r.URL.Path {
case "/ok":
var body map[string]interface{}
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
t.Log(err)
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"risks":"description", "whatWillProbablyHappenDuringMaintenance":"resolution"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
},
),
)
t.Cleanup(srv.Close)
return srv.URL
}
mockUrl := startMockServer(t)
originalUrl := getHumanInterpretationFromOsquerySqlUrl
originalTimeout := getHumanInterpretationFromOsquerySqlTimeout
t.Cleanup(
func() {
getHumanInterpretationFromOsquerySqlUrl = originalUrl
getHumanInterpretationFromOsquerySqlTimeout = originalTimeout
},
)
// Create teams
team1, err := s.ds.NewTeam(
context.Background(), &fleet.Team{
ID: 42,
Name: "team1" + t.Name(),
Description: "desc team1",
},
)
require.NoError(t, err)
team2, err := s.ds.NewTeam(
context.Background(), &fleet.Team{
ID: 43,
Name: "team2" + t.Name(),
Description: "desc team2",
},
)
require.NoError(t, err)
oldToken := s.token
t.Cleanup(
func() {
s.token = oldToken
},
)
switchUser := func(t *testing.T, role string) {
password := test.GoodPassword
email := role + "-testteam@user.com"
u := &fleet.User{
Name: "test team user",
Email: email,
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team2,
Role: fleet.RoleObserver,
},
{
Team: *team1,
Role: role,
},
},
}
require.NoError(t, u.SetPassword(password, 10, 10))
_, err = s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.token = s.getTestToken(email, password)
}
req := autofillPoliciesRequest{
SQL: "select 1",
}
getHumanInterpretationFromOsquerySqlUrl = mockUrl + "/ok"
tests := []struct {
role string
pass bool
}{
{role: fleet.RoleAdmin, pass: true},
{role: fleet.RoleMaintainer, pass: true},
{role: fleet.RoleGitOps, pass: true},
{role: fleet.RoleObserver, pass: false},
{role: fleet.RoleObserverPlus, pass: false},
}
for _, tt := range tests {
t.Run(
tt.role, func(t *testing.T) {
switchUser(t, tt.role)
if tt.pass {
var res autofillPoliciesResponse
s.DoJSON("POST", "/api/latest/fleet/autofill/policy", req, http.StatusOK, &res)
assert.Equal(t, "description", res.Description)
assert.Equal(t, "resolution", res.Resolution)
} else {
_ = s.Do("POST", "/api/latest/fleet/autofill/policy", req, http.StatusForbidden)
}
},
)
}
}
// 1. software title uploaded doesn't match existing title
// 2. host reports software with the same bundle identifier
// 3. reconciler runs, doesn't create a new title
func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
require.NoError(t, err)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some install script",
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "foo", Version: "0.0.3", Source: "homebrew"},
{Name: "bar", Version: "0.0.4", Source: "apps"},
{Name: "DummyApp", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, host, false))
require.Len(t, host.Software, 4)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
// still one because the counts didn't update yet
require.Len(t, resp.SoftwareTitles, 1)
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 3)
require.ElementsMatch(
t,
[]string{"foo", "bar", "DummyApp"},
[]string{
resp.SoftwareTitles[0].Name,
resp.SoftwareTitles[1].Name,
resp.SoftwareTitles[2].Name,
},
)
// host reports another version of dummy, but this one has a
// different name, but same identifier
software = []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "foo", Version: "0.0.3", Source: "homebrew"},
{Name: "bar", Version: "0.0.4", Source: "apps"},
{Name: "DummyApp", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"},
{Name: "AppDummy", Version: "2.0.0", Source: "apps", BundleIdentifier: "com.example.dummy"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, host, false))
require.Len(t, host.Software, 5)
require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
// titles are the same
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 3)
require.ElementsMatch(
t,
[]string{"foo", "bar", "DummyApp"},
[]string{
resp.SoftwareTitles[0].Name,
resp.SoftwareTitles[1].Name,
resp.SoftwareTitles[2].Name,
},
)
}
func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some installer script",
Filename: "no_version.pkg",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// title shows up with blank version
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.Equal(t, "NoVersion", resp.SoftwareTitles[0].Name)
require.Equal(t, "", resp.SoftwareTitles[0].SoftwarePackage.Version)
}
func (s *integrationEnterpriseTestSuite) TestPKGNoBundleIdentifier() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some installer script",
Filename: "no_bundle_identifier.pkg",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Unable to extract necessary metadata.")
}
func (s *integrationEnterpriseTestSuite) TestEXEPackageUploads() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some installer script",
Filename: "hello-world-installer.exe",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Uninstall script is required for .exe packages.")
payload = &fleet.UploadSoftwareInstallerPayload{
Filename: "hello-world-installer.exe",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Install script is required for .exe packages.")
payload = &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some installer script",
UninstallScript: "some uninstall script",
Filename: "hello-world-installer.exe",
TeamID: &team.ID,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Fleet can't create a policy to detect existing installations for .exe packages. Please add the software, add a custom policy, and enable the install software policy automation.")
payload = &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some installer script",
UninstallScript: "some uninstall script",
Filename: "hello-world-installer.exe",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
var titleID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &titleID, `SELECT title_id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, &team.ID, payload.Filename)
})
require.NotZero(t, titleID)
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String(""),
TitleID: titleID,
TeamID: &team.ID,
}, http.StatusBadRequest, "Couldn't edit. Install script is required for .exe packages.")
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
Filename: "hello-world-installer.exe",
UninstallScript: ptr.String(""),
TitleID: titleID,
TeamID: &team.ID,
}, http.StatusBadRequest, "Couldn't edit. Uninstall script is required for .exe packages.")
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
Filename: "hello-world-installer.exe",
PostInstallScript: ptr.String("foo bar baz"),
TitleID: titleID,
TeamID: &team.ID,
}, http.StatusOK, "")
}
// 1. host reports software
// 2. reconciler runs, creates title
// 3. installer is uploaded, matches existing software title
func (s *integrationEnterpriseTestSuite) TestPKGSoftwareAlreadyReported() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
require.NoError(t, err)
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "foo", Version: "0.0.3", Source: "homebrew"},
{Name: "bar", Version: "0.0.4", Source: "apps"},
// note: the source is not "apps"
{Name: "DummyApp", Version: "1.0.0", Source: "homebrew", BundleIdentifier: "com.example.dummy"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, host, false))
require.Len(t, host.Software, 4)
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 3)
require.ElementsMatch(
t,
[]string{"foo", "bar", "DummyApp"},
[]string{
resp.SoftwareTitles[0].Name,
resp.SoftwareTitles[1].Name,
resp.SoftwareTitles[2].Name,
},
)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some install script",
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 3)
require.ElementsMatch(
t,
[]string{"foo", "bar", "DummyApp"},
[]string{
resp.SoftwareTitles[0].Name,
resp.SoftwareTitles[1].Name,
resp.SoftwareTitles[2].Name,
},
)
}
// 1. host reports software
// 2. installer is uploaded, matches existing software
// 2. reconciler runs, matches existing software title
func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))
require.NoError(t, err)
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "homebrew"},
{Name: "foo", Version: "0.0.3", Source: "homebrew"},
{Name: "bar", Version: "0.0.4", Source: "apps"},
// note: the source is not "apps"
{Name: "DummyApp", Version: "1.0.0", Source: "homebrew", BundleIdentifier: "com.example.dummy"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, host, false))
require.Len(t, host.Software, 4)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some install script",
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
// only one title (the uploaded software) because the cron didn't run yet
require.Len(t, resp.SoftwareTitles, 1)
require.ElementsMatch(
t,
[]string{"DummyApp"},
[]string{resp.SoftwareTitles[0].Name},
)
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Len(t, resp.SoftwareTitles, 3)
require.ElementsMatch(
t,
[]string{"foo", "bar", "DummyApp"},
[]string{
resp.SoftwareTitles[0].Name,
resp.SoftwareTitles[1].Name,
resp.SoftwareTitles[2].Name,
},
)
}
func (s *integrationEnterpriseTestSuite) TestCalendarCallback() {
ctx := context.Background()
t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
calendar.ClearMockChannels()
})
currentAppCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err = s.ds.SaveAppConfig(ctx, currentAppCfg)
require.NoError(t, err)
})
origRecentUpdateDuration := commonCalendar.RecentCalendarUpdateDuration
commonCalendar.RecentCalendarUpdateDuration = 1 * time.Millisecond
t.Cleanup(func() {
commonCalendar.RecentCalendarUpdateDuration = origRecentUpdateDuration
})
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team1",
})
require.NoError(t, err)
newHost := func(name string, teamID *uint) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
return h
}
host1Team1 := newHost("host1", &team1.ID)
host2Team1 := newHost("host2", &team1.ID)
_ = newHost("host5", nil) // global host
team1Policy1Calendar, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy1Calendar",
Query: "SELECT 1;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
team1Policy2Calendar, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy2Calendar",
Query: "SELECT 2;",
CalendarEventsEnabled: true,
},
)
require.NoError(t, err)
globalPolicy, err := s.ds.NewGlobalPolicy(
ctx, nil, fleet.PolicyPayload{
Name: "globalPolicy",
Query: "SELECT 5;",
CalendarEventsEnabled: false,
},
)
require.NoError(t, err)
genDistributedReqWithPolicyResults := func(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim {
var (
results = make(map[string]json.RawMessage)
statuses = make(map[string]interface{})
messages = make(map[string]string)
)
for policyID, policyResult := range policyResults {
distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID)
switch {
case policyResult == nil:
results[distributedQueryName] = json.RawMessage(`[]`)
statuses[distributedQueryName] = 1
messages[distributedQueryName] = "policy failed execution"
case *policyResult:
results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`)
statuses[distributedQueryName] = 0
case !*policyResult:
results[distributedQueryName] = json.RawMessage(`[]`)
statuses[distributedQueryName] = 0
}
}
return submitDistributedQueryResultsRequestShim{
NodeKey: *host.NodeKey,
Results: results,
Statuses: statuses,
Messages: messages,
Stats: map[string]*fleet.Stats{},
}
}
// host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global).
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2Calendar.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2Calendar.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// Set global configuration for the calendar feature.
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.Integrations.GoogleCalendar = []*fleet.GoogleCalendarIntegration{
{
Domain: "example.com",
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: calendar.MockEmail,
},
},
}
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
time.Sleep(2 * time.Second) // Wait 2 seconds for the app config cache to clear.
team1.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: "https://example.com",
}
team1, err = s.ds.SaveTeam(ctx, team1)
require.NoError(t, err)
// Add email mapping for host1Team1
const user1Email = "user1@example.com"
err = s.ds.ReplaceHostDeviceMapping(ctx, host1Team1.ID, []*fleet.HostDeviceMapping{
{
HostID: host1Team1.ID,
Email: user1Email,
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
assert.Equal(t, 0, calendar.MockChannelsCount())
// Trigger the calendar cron, global feature enabled, team1 enabled
// and host1Team1 has a domain email associated.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// An event should be generated for host1Team1
team1CalendarEvents, err := s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
event := team1CalendarEvents[0]
require.NotZero(t, event.ID)
require.Equal(t, user1Email, event.Email)
require.NotZero(t, event.StartTime)
require.NotZero(t, event.EndTime)
require.NotEmpty(t, event.UUID)
bodyTag := event.GetBodyTag()
assert.NotEmpty(t, bodyTag)
assert.Equal(t, 1, calendar.MockChannelsCount())
// Get channel ID
type eventDetails struct {
ChannelID string `json:"channel_id"`
BodyTag string `json:"body_tag"`
ETag string `json:"etag"`
}
var details eventDetails
err = json.Unmarshal(event.Data, &details)
require.NoError(t, err)
// Send a sync command
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "sync",
})
// Send a regular callback with bad channel ID
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusForbidden, map[string]string{
"X-Goog-Channel-Id": "bad",
"X-Goog-Resource-State": "exists",
})
// Send a regular callback
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
// Delete the event on the calendar
calendar.ClearMockEvents()
// Grab the distributed lock for this event
distributedLock := redis_lock.NewLock(s.redisPool)
lockValue := uuid.New().String()
result, err := distributedLock.SetIfNotExist(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue, 0)
require.NoError(t, err)
assert.NotEmpty(t, result)
// This callback should put the event processing in a queue for async processing. It does not start async
// processing because it assumes another server is handling this webhook, and that server will start
// async processing.
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
uuids, err := distributedLock.GetSet(ctx, commonCalendar.QueueKey)
require.NoError(t, err)
assert.ElementsMatch(t, []string{event.UUID}, uuids)
// The calendar should still be empty since event hasn't processed yet
assert.Zero(t, len(calendar.ListGoogleMockEvents()))
// We clear the queue
assert.NoError(t, distributedLock.RemoveFromSet(ctx, commonCalendar.QueueKey, event.UUID))
// We release the normal lock, but grab the reserve lock instead
ok, err := distributedLock.ReleaseLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue)
require.NoError(t, err)
assert.True(t, ok)
result, err = distributedLock.SetIfNotExist(ctx, commonCalendar.ReservedLockKeyPrefix+event.UUID, lockValue, 0)
require.NoError(t, err)
assert.NotEmpty(t, result)
// This callback should put the event processing in a queue for async processing, AND start the async processing
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
uuids, err = distributedLock.GetSet(ctx, commonCalendar.QueueKey)
require.NoError(t, err)
assert.ElementsMatch(t, []string{event.UUID}, uuids)
// The calendar should still be empty since event hasn't processed yet
assert.Zero(t, len(calendar.ListGoogleMockEvents()))
// We grab the normal lock again.
lockValue2 := uuid.New().String()
result, err = distributedLock.SetIfNotExist(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue2, 0)
require.NoError(t, err)
assert.NotEmpty(t, result)
// We release the reserve lock.
ok, err = distributedLock.ReleaseLock(ctx, commonCalendar.ReservedLockKeyPrefix+event.UUID, lockValue)
require.NoError(t, err)
assert.True(t, ok)
// We release the normal lock.
ok, err = distributedLock.ReleaseLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue2)
require.NoError(t, err)
assert.True(t, ok)
done := make(chan struct{})
go func() {
for {
time.Sleep(100 * time.Millisecond)
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
// Event should be rescheduled on a future date/time
if len(team1CalendarEvents) == 1 && team1CalendarEvents[0].UUID == event.UUID &&
team1CalendarEvents[0].StartTime.After(event.StartTime) {
done <- struct{}{}
return
}
}
}()
select {
case <-done: // All good
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for calendar event processing")
}
eventRecreated := team1CalendarEvents[0]
assert.NotZero(t, eventRecreated.ID)
assert.Equal(t, user1Email, eventRecreated.Email)
assert.NotZero(t, eventRecreated.StartTime)
assert.NotZero(t, eventRecreated.EndTime)
assert.NotEmpty(t, eventRecreated.UUID)
assert.NotEqual(t, event.StartTime, eventRecreated.StartTime)
assert.NotEqual(t, event.EndTime, eventRecreated.EndTime)
assert.Equal(t, 1, calendar.MockChannelsCount())
assert.Equal(t, 1, len(calendar.ListGoogleMockEvents()))
// The previous event UUID should not work anymore, but API returns OK because this is a common occurrence.
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
err = json.Unmarshal(eventRecreated.Data, &details)
require.NoError(t, err)
assert.NotEmpty(t, details.BodyTag)
bodyTag = details.BodyTag
// New event callback should work
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
// Update the time of the event
events := calendar.ListGoogleMockEvents()
require.Len(t, events, 1)
for _, e := range events {
st, err := time.Parse(time.RFC3339, e.Start.DateTime)
require.NoError(t, err)
newStartTime := st.Add(5 * time.Minute).Format(time.RFC3339)
e.Start.DateTime = newStartTime
}
// New event callback should cause the time to be updated in the DB
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
// Check that the time was updated in the DB
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
eventUpdated := team1CalendarEvents[0]
assert.NotZero(t, eventUpdated.ID)
assert.Equal(t, user1Email, eventUpdated.Email)
assert.Equal(t, eventRecreated.UUID, eventUpdated.UUID)
assert.Greater(t, eventUpdated.StartTime, eventRecreated.StartTime)
assert.Equal(t, eventRecreated.EndTime, eventUpdated.EndTime)
assert.Equal(t, 1, calendar.MockChannelsCount())
assert.Equal(t, bodyTag, eventRecreated.GetBodyTag())
// Change the body contents of event.
events = calendar.ListGoogleMockEvents()
require.Len(t, events, 1)
eTag := "description change etag"
for _, e := range events {
e.Etag = eTag
e.Description = "new description"
}
// New event callback should cause Etag to update but Body tag to remain the same
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
eventDescUpdated := team1CalendarEvents[0]
err = json.Unmarshal(eventDescUpdated.Data, &details)
require.NoError(t, err)
assert.Equal(t, bodyTag, details.BodyTag)
assert.Equal(t, eTag, details.ETag)
// Update the time of the event again
events = calendar.ListGoogleMockEvents()
require.Len(t, events, 1)
for _, e := range events {
st, err := time.Parse(time.RFC3339, e.Start.DateTime)
require.NoError(t, err)
newStartTime := st.Add(5 * time.Minute).Format(time.RFC3339)
e.Start.DateTime = newStartTime
e.Etag += "1"
}
// Grab the lock
event = eventUpdated
lockValue = uuid.New().String()
result, err = distributedLock.SetIfNotExist(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue, 0)
require.NoError(t, err)
assert.NotEmpty(t, result)
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
// Update updated_at so the event gets updated (the event is updated regularly)
_, err := db.ExecContext(ctx,
`UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 HOUR) WHERE id = ?`, event.ID)
return err
})
// Trigger the calendar cron async. It should wait for the lock and set reserve lock.
go triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 10*time.Second)
done = make(chan struct{})
go func() {
for {
time.Sleep(100 * time.Millisecond)
reserveLock, err := distributedLock.Get(ctx, commonCalendar.ReservedLockKeyPrefix+event.UUID)
require.NoError(t, err)
if reserveLock != nil {
done <- struct{}{}
return
}
}
}()
select {
case <-done: // All good
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for cron to set reserve lock")
}
// Release the normal lock
ok, err = distributedLock.ReleaseLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue)
require.NoError(t, err)
assert.True(t, ok)
// Wait for the event to update
done = make(chan struct{})
go func() {
for {
time.Sleep(100 * time.Millisecond)
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
if len(team1CalendarEvents) == 1 && team1CalendarEvents[0].UUID == event.UUID &&
team1CalendarEvents[0].StartTime.After(event.StartTime) {
err = json.Unmarshal(team1CalendarEvents[0].Data, &details)
require.NoError(t, err)
assert.NotEqual(t, eTag, details.ETag, "ETag should have updated")
done <- struct{}{}
return
}
}
}()
select {
case <-done: // All good
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for event to update during cron")
}
// Delete the event on the calendar
calendar.ClearMockEvents()
// Make host1Team1 pass all policies.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2Calendar.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// We set a flag that event was updated recently. Callback shouldn't do anything since event was updated recently
_, err = distributedLock.SetIfNotExist(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue,
1000)
require.NoError(t, err)
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
assert.Equal(t, 1, calendar.MockChannelsCount())
// Callback should work, but only clear the callback channel. Event in DB will be deleted on the next cron run.
_, err = distributedLock.ReleaseLock(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue)
require.NoError(t, err)
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
assert.Equal(t, 0, calendar.MockChannelsCount())
previousEvent := team1CalendarEvents[0]
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
assert.Equal(t, previousEvent, team1CalendarEvents[0])
err = s.ds.DeleteHost(ctx, host1Team1.ID)
require.NoError(t, err)
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
// Trigger calendar should cleanup the events
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
assert.Equal(t, 0, calendar.MockChannelsCount())
// Event should be cleaned up from our database.
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
assert.Empty(t, team1CalendarEvents)
}
func (s *integrationEnterpriseTestSuite) TestCalendarEventBodyUpdate() {
ctx := context.Background()
t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
calendar.ClearMockChannels()
})
currentAppCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err = s.ds.SaveAppConfig(ctx, currentAppCfg)
require.NoError(t, err)
})
origRecentUpdateDuration := commonCalendar.RecentCalendarUpdateDuration
commonCalendar.RecentCalendarUpdateDuration = 1 * time.Millisecond
t.Cleanup(func() {
commonCalendar.RecentCalendarUpdateDuration = origRecentUpdateDuration
})
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team1",
})
require.NoError(t, err)
newHost := func(name string, teamID *uint) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
return h
}
host1Team1 := newHost("host1", &team1.ID)
host2Team1 := newHost("host2", &team1.ID)
_ = newHost("host5", nil) // global host
team1Policy1Calendar, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy1Calendar",
Query: "SELECT 1;",
CalendarEventsEnabled: true,
Description: "team1Policy1CalendarDescription",
Resolution: "team1Policy1CalendarResolution",
},
)
require.NoError(t, err)
team1Policy2Calendar, err := s.ds.NewTeamPolicy(
ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "team1Policy2Calendar",
Query: "SELECT 2;",
CalendarEventsEnabled: true,
Description: "team1Policy2CalendarDescription",
Resolution: "team1Policy2CalendarResolution",
},
)
require.NoError(t, err)
globalPolicy, err := s.ds.NewGlobalPolicy(
ctx, nil, fleet.PolicyPayload{
Name: "globalPolicy",
Query: "SELECT 5;",
CalendarEventsEnabled: false,
Description: "globalPolicyDescription",
Resolution: "globalPolicyResolution",
},
)
require.NoError(t, err)
genDistributedReqWithPolicyResults := func(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim {
var (
results = make(map[string]json.RawMessage)
statuses = make(map[string]interface{})
messages = make(map[string]string)
)
for policyID, policyResult := range policyResults {
distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID)
switch {
case policyResult == nil:
results[distributedQueryName] = json.RawMessage(`[]`)
statuses[distributedQueryName] = 1
messages[distributedQueryName] = "policy failed execution"
case *policyResult:
results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`)
statuses[distributedQueryName] = 0
case !*policyResult:
results[distributedQueryName] = json.RawMessage(`[]`)
statuses[distributedQueryName] = 0
}
}
return submitDistributedQueryResultsRequestShim{
NodeKey: *host.NodeKey,
Results: results,
Statuses: statuses,
Messages: messages,
Stats: map[string]*fleet.Stats{},
}
}
// host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global).
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2Calendar.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global).
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(true),
team1Policy2Calendar.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
// Set global configuration for the calendar feature.
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.Integrations.GoogleCalendar = []*fleet.GoogleCalendarIntegration{
{
Domain: "example.com",
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: calendar.MockEmail,
},
},
}
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
time.Sleep(2 * time.Second) // Wait 2 seconds for the app config cache to clear.
team1.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: "https://example.com",
}
team1, err = s.ds.SaveTeam(ctx, team1)
require.NoError(t, err)
// Add email mapping for host1Team1
const user1Email = "user1@example.com"
err = s.ds.ReplaceHostDeviceMapping(ctx, host1Team1.ID, []*fleet.HostDeviceMapping{
{
HostID: host1Team1.ID,
Email: user1Email,
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
assert.Equal(t, 0, calendar.MockChannelsCount())
// Trigger the calendar cron, global feature enabled, team1 enabled
// and host1Team1 has a domain email associated.
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
// An event should be generated for host1Team1
team1CalendarEvents, err := s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
event := team1CalendarEvents[0]
require.NotZero(t, event.ID)
require.Equal(t, user1Email, event.Email)
require.NotZero(t, event.StartTime)
require.NotZero(t, event.EndTime)
require.NotEmpty(t, event.UUID)
assert.Equal(t, 1, calendar.MockChannelsCount())
getEvents := func() []*googleCalendar.Event {
calEvents := calendar.ListGoogleMockEvents()
calEventValues := make([]*googleCalendar.Event, 0, len(calEvents))
for _, v := range calEvents {
calEventValues = append(calEventValues, v)
}
return calEventValues
}
calEvents := getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, team1Policy1Calendar.Description)
assert.Contains(t, calEvents[0].Description, *team1Policy1Calendar.Resolution)
// Remove resolution from policy
team1Policy1Calendar.Resolution = nil
require.NoError(t, s.ds.SavePolicy(ctx, team1Policy1Calendar, false, false))
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultDescription)
assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultResolution)
// Put resolution back
team1Policy1Calendar.Resolution = ptr.String("putResolutionBack")
require.NoError(t, s.ds.SavePolicy(ctx, team1Policy1Calendar, false, false))
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, team1Policy1Calendar.Description)
assert.Contains(t, calEvents[0].Description, *team1Policy1Calendar.Resolution)
// Change resolution
team1Policy1Calendar.Resolution = ptr.String("changeResolution")
require.NoError(t, s.ds.SavePolicy(ctx, team1Policy1Calendar, false, false))
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, team1Policy1Calendar.Description)
assert.Contains(t, calEvents[0].Description, *team1Policy1Calendar.Resolution)
// Cause another policy to fail
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2Calendar.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultDescription)
assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultResolution)
// Cause the other policy to pass
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
team1Policy1Calendar.ID: ptr.Bool(false),
team1Policy2Calendar.ID: ptr.Bool(true),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, team1Policy1Calendar.Description)
assert.Contains(t, calEvents[0].Description, *team1Policy1Calendar.Resolution)
// Get channel ID
type eventDetails struct {
ChannelID string `json:"channel_id"`
}
var details eventDetails
err = json.Unmarshal(event.Data, &details)
require.NoError(t, err)
// Delete the event on the calendar
calendar.ClearMockEvents()
// Send a regular callback
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
// Make sure event body is correct after event was recreated
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, team1Policy1Calendar.Description)
assert.Contains(t, calEvents[0].Description, *team1Policy1Calendar.Resolution)
// Get the new event
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
require.Len(t, team1CalendarEvents, 1)
event = team1CalendarEvents[0]
err = json.Unmarshal(event.Data, &details)
require.NoError(t, err)
// Remove description from policy
team1Policy1Calendar.Description = " "
require.NoError(t, s.ds.SavePolicy(ctx, team1Policy1Calendar, false, false))
// Delete the event on the calendar
calendar.ClearMockEvents()
// Send a regular callback
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
"X-Goog-Resource-State": "exists",
})
// Make sure event body is correct after event was recreated
calEvents = getEvents()
require.Len(t, calEvents, 1)
assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultDescription)
assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultResolution)
}
func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() {
t := s.T()
ctx := context.Background()
// Create host
orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds)
test.CreateInsertGlobalVPPToken(t, s.ds)
// Create team and add host to team
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
s.Do("POST", "/api/latest/fleet/hosts/transfer", &addHostsToTeamRequest{HostIDs: []uint{orbitHost.ID}, TeamID: &team.ID}, http.StatusOK)
// Add an app so that we don't get a not found error
app, err := s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "App " + t.Name(),
BundleIdentifier: "bid_" + t.Name(),
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "adam_test_vpp1", // max 16 chars
Platform: fleet.MacOSPlatform,
},
},
}, &team.ID)
require.NoError(t, err)
pkgPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some pkg install script",
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "")
// We don't see VPP, but we do still see the installers
resp := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", orbitHost.ID), getHostSoftwareRequest{}, http.StatusOK, &resp)
assert.Len(t, resp.Software, 1)
assert.NotNil(t, resp.Software[0].SoftwarePackage)
assert.Nil(t, resp.Software[0].AppStoreApp)
r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", orbitHost.ID, app.TitleID), &installSoftwareRequest{},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), "Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps.")
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers() {
t := s.T()
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, s.ds)
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"})
require.NoError(t, err)
newHost := func(name string, teamID *uint, platform string) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: platform,
TeamID: teamID,
})
require.NoError(t, err)
return h
}
newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host {
h := newHost(name, teamID, platform)
orbitKey := setOrbitEnrollment(t, h, s.ds)
h.OrbitNodeKey = &orbitKey
return h
}
host0NoTeam := newFleetdHost("host1NoTeam", nil, "darwin")
host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin")
host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu")
host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows")
hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin")
// Upload dummy_installer.pkg to team1.
pkgPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some pkg install script",
Filename: "dummy_installer.pkg",
TeamID: &team1.ID,
}
s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "DummyApp",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
dummyInstallerPkgTitleID := resp.SoftwareTitles[0].ID
var dummyInstallerPkg struct {
ID uint `db:"id"`
UserID *uint `db:"user_id"`
UserName string `db:"user_name"`
UserEmail string `db:"user_email"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&dummyInstallerPkg,
`SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`,
team1.ID, "dummy_installer.pkg",
)
})
dummyInstallerPkgInstallerID := dummyInstallerPkg.ID
require.NotZero(t, dummyInstallerPkgInstallerID)
require.NotNil(t, dummyInstallerPkg.UserID)
globalAdmin, err := s.ds.UserByEmail(ctx, "admin1@example.com")
require.NoError(t, err)
require.Equal(t, globalAdmin.ID, *dummyInstallerPkg.UserID)
require.Equal(t, "Test Name admin1@example.com", dummyInstallerPkg.UserName)
require.Equal(t, "admin1@example.com", dummyInstallerPkg.UserEmail)
// Upload ruby.deb to team1 by a user who will be deleted.
u2 := &fleet.User{
Name: "admin team1",
Email: "admin_team1@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team1,
Role: fleet.RoleAdmin,
},
},
}
require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10))
adminTeam1, err := s.ds.NewUser(context.Background(), u2)
require.NoError(t, err)
rubyPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "ruby.deb",
TeamID: &team1.ID,
}
adminTeam1Session, err := s.ds.NewSession(ctx, adminTeam1.ID, 64)
require.NoError(t, err)
adminToken := s.token
t.Cleanup(func() {
s.token = adminToken
})
s.token = adminTeam1Session.Key
s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
s.token = adminToken
err = s.ds.DeleteUser(ctx, adminTeam1.ID)
require.NoError(t, err)
// Get software title ID of the uploaded installer.
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
rubyDebTitleID := resp.SoftwareTitles[0].ID
var rubyDeb struct {
ID uint `db:"id"`
UserID *uint `db:"user_id"`
UserName string `db:"user_name"`
UserEmail string `db:"user_email"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&rubyDeb,
`SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`,
team1.ID, "ruby.deb",
)
})
rubyDebInstallerID := rubyDeb.ID
require.NotZero(t, rubyDebInstallerID)
require.Nil(t, rubyDeb.UserID)
require.Equal(t, "admin team1", rubyDeb.UserName)
require.Equal(t, "admin_team1@example.com", rubyDeb.UserEmail)
// Upload fleet-osquery.msi to team2.
fleetOsqueryPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some msi install script",
Filename: "fleet-osquery.msi",
TeamID: &team2.ID,
// Set as Self-service to check that the generated host_software_installs
// is generated with self_service=false and the activity has the correct
// author (the admin that uploaded the installer).
SelfService: true,
}
s.uploadSoftwareInstaller(t, fleetOsqueryPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "Fleet osquery",
"team_id", fmt.Sprintf("%d", team2.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
fleetOsqueryMSITitleID := resp.SoftwareTitles[0].ID
var fleetOsqueryMSIInstallerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&fleetOsqueryMSIInstallerID,
`SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`,
team2.ID, "fleet-osquery.msi",
)
})
require.NotZero(t, fleetOsqueryMSIInstallerID)
// Create a VPP app to test that policies *can* be assigned to them (VPP automation is tested in MDM integration test)
_, err = s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "App123 " + t.Name(),
BundleIdentifier: "bid_" + t.Name(),
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "adam_test_vpp2", // max 16 chars
Platform: fleet.MacOSPlatform,
},
},
}, &team1.ID)
require.NoError(t, err)
// Get software title ID of the uploaded VPP app.
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "App123",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].AppStoreApp)
vppAppTitleID := resp.SoftwareTitles[0].ID
// Populate software for host1Team1 (to have a software title
// that doesn't have an associated installer)
software := []fleet.Software{
{Name: "Foobar", Version: "0.0.1", Source: "apps"},
}
_, err = s.ds.UpdateHostSoftware(ctx, host1Team1.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now()))
// Get software title ID of the software.
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "Foobar",
"team_id", fmt.Sprintf("%d", team1.ID),
)
require.Len(t, resp.SoftwareTitles, 1)
require.Nil(t, resp.SoftwareTitles[0].SoftwarePackage)
foobarAppTitleID := resp.SoftwareTitles[0].ID
// policy0AllTeams is a global policy that runs on all devices.
policy0AllTeams, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{
Name: "policy0AllTeams",
Query: "SELECT 1;",
Platform: "darwin",
})
require.NoError(t, err)
// policy1Team1 runs on macOS devices.
policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "policy1Team1",
Query: "SELECT 1;",
Platform: "darwin",
})
require.NoError(t, err)
// policy2Team1 runs on macOS and Linux devices.
policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "policy2Team1",
Query: "SELECT 2;",
Platform: "linux,darwin",
})
require.NoError(t, err)
// policy3Team1 runs on all devices in team1 (will have no associated installers).
policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "policy3Team1",
Query: "SELECT 3;",
})
require.NoError(t, err)
// policy4Team2 runs on Windows devices.
policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
Name: "policy4Team2",
Query: "SELECT 4;",
Platform: "windows",
})
require.NoError(t, err)
// Attempt to associate to an unknown software title.
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: 999_999},
},
}, http.StatusBadRequest, &mtplr)
// Attempt to associate to a software title without associated installer.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: foobarAppTitleID},
},
}, http.StatusBadRequest, &mtplr)
// Associate a VPP app to the policy (which we'll immediately overwrite)
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vppAppTitleID},
},
}, http.StatusOK, &mtplr)
// Associate dummy_installer.pkg to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: dummyInstallerPkgTitleID},
},
}, http.StatusOK, &mtplr)
// Change name only (to test not setting a software_title_id).
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID),
json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr,
)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.SoftwareInstallerID)
require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID)
require.Equal(t, "policy1Team1_Renamed", policy1Team1.Name)
// Explicit set to 0 to disable.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: 0},
},
}, http.StatusOK, &mtplr)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Nil(t, policy1Team1.SoftwareInstallerID)
// re-add software installer to policy1Team1
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: dummyInstallerPkgTitleID},
},
}, http.StatusOK, &mtplr)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.SoftwareInstallerID)
require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID)
// Set to null to disable
mtplr = modifyTeamPolicyResponse{}
s.DoRaw("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), []byte(`{
"software_title_id": null
}`), http.StatusOK)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Nil(t, policy1Team1.SoftwareInstallerID)
host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Add some results and stats that should be cleared after setting an installer again.
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(1), policy1Team1.FailingHostCount)
passes := true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&passes,
`SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`,
policy1Team1.ID, host1Team1.ID,
)
})
require.False(t, passes)
// Back to associating dummy_installer.pkg to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: dummyInstallerPkgTitleID},
},
}, http.StatusOK, &mtplr)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.SoftwareInstallerID)
require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID)
// Policy stats and membership should be cleared from policy1Team1.
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(0), policy1Team1.FailingHostCount)
countBiggerThanZero := true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&countBiggerThanZero,
`SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`,
policy1Team1.ID,
)
})
require.False(t, countBiggerThanZero)
// Add (again) some results and stats that should be cleared after changing an existing installer.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(1), policy1Team1.FailingHostCount)
passes = true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&passes,
`SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`,
policy1Team1.ID, host1Team1.ID,
)
})
require.False(t, passes)
// Change the installer (temporarily to test that changing an installer will clear results)
// Associate ruby.deb to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
// After changing the installer, membership and stats should be cleared.
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.SoftwareInstallerID)
require.Equal(t, rubyDebInstallerID, *policy1Team1.SoftwareInstallerID)
// Policy stats and membership should be cleared from policy1Team1.
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(0), policy1Team1.FailingHostCount)
countBiggerThanZero = true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&countBiggerThanZero,
`SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`,
policy1Team1.ID,
)
})
require.False(t, countBiggerThanZero)
// Back to (again) associating dummy_installer.pkg to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: dummyInstallerPkgTitleID},
},
}, http.StatusOK, &mtplr)
// Associate ruby.deb to policy2Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
// We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the
// current user's "Authorization: Bearer " header.
// host1Team1 fails all policies on the first report.
// Failing policy1Team1 means an install request must be generated.
// Failing policy2Team1 should not trigger a install request because it has a .deb attached to it (does not apply to macOS hosts).
// Failing policy3Team1 should do nothing because it doesn't have any installers associated to it.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
require.NotEmpty(t, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
prevExecutionID := host1LastInstall.ExecutionID
// Request a manual installation on the host for the same installer, which should fail.
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
host1Team1.ID, dummyInstallerPkgTitleID), nil, http.StatusBadRequest, &installResp)
// Submit same results as before, which should not trigger a installation because the policy is already failing.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// Submit same results but policy1Team1 now passes,
// and then submit again but policy1Team1 fails.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(true),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// Another installation should not be triggered because the last installation is pending.
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// host2Team1 is failing policy2Team1 and policy3Team1 policies.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
host2LastInstall, err := s.ds.GetHostLastInstallData(ctx, host2Team1.ID, rubyDebInstallerID)
require.NoError(t, err)
require.NotNil(t, host2LastInstall)
require.NotEmpty(t, host2LastInstall.ExecutionID)
require.NotNil(t, host2LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host2LastInstall.Status)
// Associate fleet-osquery.msi to policy4Team2.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: fleetOsqueryMSITitleID},
},
}, http.StatusOK, &mtplr)
// host3Team2 reports a failing result for policy4Team2, which should trigger an installation.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
policy4Team2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
host3LastInstall, err := s.ds.GetHostLastInstallData(ctx, host3Team2.ID, fleetOsqueryMSIInstallerID)
require.NoError(t, err)
require.NotNil(t, host3LastInstall)
require.NotEmpty(t, host3LastInstall.ExecutionID)
require.NotNil(t, host3LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host3LastInstall.Status)
host3LastInstallDetails, err := s.ds.GetSoftwareInstallDetails(ctx, host3LastInstall.ExecutionID)
require.NoError(t, err)
// Even if fleet-osquery.msi was uploaded as Self-service, it was installed by Fleet, so
// host3LastInstallDetails.SelfService should be false.
require.False(t, host3LastInstallDetails.SelfService)
//
// The following increase coverage of policies result processing in distributed/write.
//
// host3Team2 reports a passing result for policy0AllTeams which is a global policy.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
policy0AllTeams.ID: ptr.Bool(true),
},
), http.StatusOK, &distributedResp)
// host0NoTeam reports a failing result for policy0AllTeams which is a global policy.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host0NoTeam,
map[uint]*bool{
policy0AllTeams.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// host3Team2 reports a failing result for policy0AllTeams which is a global policy.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
policy0AllTeams.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// Unassociate policy4Team2 from installer.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: 0},
},
}, http.StatusOK, &mtplr)
// host3Team2 reports a failing result for policy4Team2.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
policy4Team2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// Upcoming activities for host1Team1 should show the automatic installation of dummy_installer.pkg.
// Check the author should be the admin that uploaded the installer.
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1Team1.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
require.Nil(t, listUpcomingAct.Activities[0].ActorID)
require.Equal(t, "Fleet", *listUpcomingAct.Activities[0].ActorFullName)
require.Nil(t, listUpcomingAct.Activities[0].ActorEmail)
//
// Finally have orbit install the packages and check activities.
//
// host1Team1 posts the installation result for dummy_installer.pkg.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *host1Team1.OrbitNodeKey, host1LastInstall.ExecutionID)), http.StatusNoContent)
s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
"host_id": %d,
"host_display_name": "%s",
"software_title": "%s",
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "installed",
"policy_id": %d,
"policy_name": "%s"
}`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp", "dummy_installer.pkg", host1LastInstall.ExecutionID, policy1Team1.ID, policy1Team1.Name), 0)
// host2Team1 posts the installation result for ruby.deb.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 1,
"install_script_output": "failed"
}`, *host2Team1.OrbitNodeKey, host2LastInstall.ExecutionID)), http.StatusNoContent)
activityID := s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
"host_id": %d,
"host_display_name": "%s",
"software_title": "%s",
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "%s",
"policy_id": %d,
"policy_name": "%s"
}`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID, fleet.SoftwareInstallFailed, policy2Team1.ID, policy2Team1.Name), 0)
// Check that the activity item generated for ruby.deb installation is shown as coming from Fleet
var actor struct {
UserID *uint `db:"user_id"`
UserName *string `db:"user_name"`
UserEmail string `db:"user_email"`
PolicyID *uint `db:"policy_id"`
PolicyName *string `db:"policy_name"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&actor,
`SELECT user_id, user_name, user_email, details->>'$.policy_id' policy_id, details->>'$.policy_name' policy_name FROM activities WHERE id = ?`,
activityID,
)
})
require.Nil(t, actor.UserID)
require.NotNil(t, actor.UserName)
require.Equal(t, "Fleet", *actor.UserName)
require.Equal(t, "", actor.UserEmail)
require.Equal(t, policy2Team1.ID, *actor.PolicyID)
require.Equal(t, policy2Team1.Name, *actor.PolicyName)
// host3Team2 posts the installation result for fleet-osquery.msi.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 1,
"install_script_output": "failed"
}`, *host3Team2.OrbitNodeKey, host3LastInstall.ExecutionID)), http.StatusNoContent)
activityID = s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
"host_id": %d,
"host_display_name": "%s",
"software_title": "%s",
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "%s",
"policy_id": %f,
"policy_name": "%s"
}`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID,
fleet.SoftwareInstallFailed, float64(policy4Team2.ID), policy4Team2.Name), 0)
// Check that the activity item generated for fleet-osquery.msi installation has Fleet set as author.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&actor,
`SELECT user_id, user_name, user_email, details->>'$.policy_id' policy_id, details->>'$.policy_name' policy_name FROM activities WHERE id = ?`,
activityID,
)
})
require.Nil(t, actor.UserID)
require.NotNil(t, actor.UserName)
require.Equal(t, "Fleet", *actor.UserName)
require.Equal(t, "", actor.UserEmail)
require.Equal(t, policy4Team2.ID, *actor.PolicyID)
require.Equal(t, policy4Team2.Name, *actor.PolicyName)
// hostVanillaOsquery5Team1 sends policy results with failed policies with associated installers.
// Fleet should not queue an install for vanilla osquery hosts.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
hostVanillaOsquery5Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
hostVanillaOsquery5Team1LastInstall, err := s.ds.GetHostLastInstallData(ctx, hostVanillaOsquery5Team1.ID, dummyInstallerPkgInstallerID)
require.NoError(t, err)
require.Nil(t, hostVanillaOsquery5Team1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallersLabelScoping() {
t := s.T()
ctx := context.Background()
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
// Create a few labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 2",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 3",
}, http.StatusOK, &newLabelResp)
lbl3 := newLabelResp.Label
// Add label1 and label2 to the host
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// upload software. Add label1 and label3 as "exclude any" labels.
rubyPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "ruby.deb",
TeamID: nil,
LabelsExcludeAny: []string{lbl1.Name, lbl3.Name},
Platform: "linux",
}
s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
rubyDebTitleID := resp.SoftwareTitles[0].ID
var rubyDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail)
require.NotNil(t, rubyDetail.SoftwareTitle)
require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage)
rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID
policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "policy1",
Query: "SELECT 1;",
Platform: "linux",
})
require.NoError(t, err)
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Send back a failed result for the policy.
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is not in scope, we do not mark the policy as failed.
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(0), policy1.FailingHostCount)
// No installation attempt, because we skipped due to label scoping
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
vimPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "vim.deb",
TeamID: nil,
LabelsIncludeAny: []string{lbl1.Name, lbl2.Name},
Platform: "linux",
}
s.uploadSoftwareInstaller(t, vimPayload, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "vim",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
vimTitleID := resp.SoftwareTitles[0].ID
var vimDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", vimTitleID), nil, http.StatusOK, &vimDetail)
require.NotNil(t, vimDetail.SoftwareTitle)
require.NotNil(t, vimDetail.SoftwareTitle.SoftwarePackage)
vimInstallerID := vimDetail.SoftwareTitle.SoftwarePackage.InstallerID
policy2, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "policy2",
Query: "SELECT 2;",
Platform: "linux",
})
require.NoError(t, err)
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vimTitleID},
},
}, http.StatusOK, &mtplr)
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy2, err = s.ds.Policy(ctx, policy2.ID)
require.NoError(t, err)
// Because the installer is in scope, we do mark the policy as failed.
require.Equal(t, uint(0), policy2.PassingHostCount)
require.Equal(t, uint(1), policy2.FailingHostCount)
// We have an installation attempt for vim, because it's in scope
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationLabelScopingRetrigger() {
t := s.T()
ctx := context.Background()
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
// Create a few labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 2",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 3",
}, http.StatusOK, &newLabelResp)
lbl3 := newLabelResp.Label
// Add label1 and label2 to the host
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// upload software. Add label1 and label3 as "exclude any" labels.
rubyPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "ruby.deb",
TeamID: nil,
LabelsIncludeAny: []string{lbl1.Name, lbl3.Name},
Platform: "linux",
}
s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
rubyDebTitleID := resp.SoftwareTitles[0].ID
var rubyDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail)
require.NotNil(t, rubyDetail.SoftwareTitle)
require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage)
rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID
policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "policy1",
Query: "SELECT 1;",
Platform: "linux",
})
require.NoError(t, err)
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
// No install attempt yet
host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Send back a failed result for the policy.
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is in scope, the policy is failing
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(1), policy1.FailingHostCount)
// We've triggered an installation attempt
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
// Update the installer's labels to "exclude any". This de-scopes the software.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsExcludeAny: []string{lbl1.Name, lbl3.Name},
}, http.StatusOK, "")
// The update should clear out the installation attempt
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Update the installer's labels to be "include any" again. The software is now back in scope.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsIncludeAny: []string{lbl1.Name, lbl3.Name},
}, http.StatusOK, "")
// Simulate a failure of the policy
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is in scope, the policy should be failing again
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(1), policy1.FailingHostCount)
// We have an installation attempt again; the policy automation has been re-triggered
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
// Update the include any labels. The host doesn't have label2, so this means that the software
// moved out of scope.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsIncludeAny: []string{lbl2.Name},
}, http.StatusOK, "")
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Update to exclude any with label 2. This moves the software back into scope. The policy
// automation should re-trigger.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsExcludeAny: []string{lbl2.Name},
}, http.StatusOK, "")
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is in scope, the policy should be failing again.
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(1), policy1.FailingHostCount)
// We have an installation attempt again.
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
t := s.T()
ctx := context.Background()
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"})
require.NoError(t, err)
newHost := func(name string, teamID *uint, platform string) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: platform,
TeamID: teamID,
})
require.NoError(t, err)
return h
}
newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host {
h := newHost(name, teamID, platform)
orbitKey := setOrbitEnrollment(t, h, s.ds)
h.OrbitNodeKey = &orbitKey
return h
}
host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin")
host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu")
host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows")
hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin")
// Upload script to team1.
script, err := s.ds.NewScript(ctx, &fleet.Script{
Name: "unix-script.sh",
ScriptContents: "echo 'Hello World'",
TeamID: &team1.ID,
})
require.NoError(t, err)
require.NotZero(t, script.ID)
// Upload winScript to team1.
winScript, err := s.ds.NewScript(ctx, &fleet.Script{
Name: "windows-script.ps1",
ScriptContents: "beep boop I am a windoge",
TeamID: &team1.ID,
})
require.NoError(t, err)
require.NotZero(t, winScript.ID)
// Upload script to team2.
psScript, err := s.ds.NewScript(ctx, &fleet.Script{
Name: "windows-script.ps1",
ScriptContents: "beep boop I am a window",
TeamID: &team2.ID,
})
require.NoError(t, err)
require.NotZero(t, psScript.ID)
// craete a global policy that runs on all devices.
_, err = s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{
Name: "policy0AllTeams",
Query: "SELECT 1;",
Platform: "darwin",
})
require.NoError(t, err)
// policy1Team1 runs on macOS devices.
policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "policy1Team1",
Query: "SELECT 1;",
Platform: "darwin",
})
require.NoError(t, err)
// policy2Team1 runs on macOS and Linux devices.
policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "policy2Team1",
Query: "SELECT 2;",
Platform: "linux,darwin",
})
require.NoError(t, err)
// policy3Team1 runs on all devices in team1 (will have no associated scripts).
policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
Name: "policy3Team1",
Query: "SELECT 3;",
})
require.NoError(t, err)
// policy4Team2 runs on Windows devices.
policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
Name: "policy4Team2",
Query: "SELECT 4;",
Platform: "windows",
})
require.NoError(t, err)
// Attempt to associate to an unknown script.
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: 999_999},
},
}, http.StatusBadRequest, &mtplr)
// Associate first script to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: script.ID},
},
}, http.StatusOK, &mtplr)
// Change name only (to test not setting a script_id).
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID),
json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr,
)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.ScriptID)
require.Equal(t, script.ID, *policy1Team1.ScriptID)
require.Equal(t, "policy1Team1_Renamed", policy1Team1.Name)
// Explicit set to 0 to disable.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: 0},
},
}, http.StatusOK, &mtplr)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Nil(t, policy1Team1.ScriptID)
// re-add script to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: script.ID},
},
}, http.StatusOK, &mtplr)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.ScriptID)
require.Equal(t, script.ID, *policy1Team1.ScriptID)
// set to null to disable
mtplr = modifyTeamPolicyResponse{}
s.DoRaw("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), []byte(`{
"script_id": null
}`), http.StatusOK)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Nil(t, policy1Team1.ScriptID)
// Add some results and stats that should be cleared after updating the script
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(1), policy1Team1.FailingHostCount)
passes := true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&passes,
`SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`,
policy1Team1.ID, host1Team1.ID,
)
})
require.False(t, passes)
// Back to associating the script with policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: script.ID},
},
}, http.StatusOK, &mtplr)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.ScriptID)
require.Equal(t, script.ID, *policy1Team1.ScriptID)
// Policy stats and membership should be cleared from policy1Team1.
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(0), policy1Team1.FailingHostCount)
countBiggerThanZero := true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&countBiggerThanZero,
`SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`,
policy1Team1.ID,
)
})
require.False(t, countBiggerThanZero)
// Add (again) some results and stats that should be cleared after changing an existing script.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(1), policy1Team1.FailingHostCount)
passes = true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&passes,
`SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`,
policy1Team1.ID, host1Team1.ID,
)
})
require.False(t, passes)
// Change the script (temporarily to test that changing a script will clear results)
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: winScript.ID},
},
}, http.StatusOK, &mtplr)
// After changing the script, membership and stats should be cleared.
policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
require.NoError(t, err)
require.NotNil(t, policy1Team1.ScriptID)
require.Equal(t, winScript.ID, *policy1Team1.ScriptID)
// Policy stats and membership should be cleared from policy1Team1.
require.Equal(t, uint(0), policy1Team1.PassingHostCount)
require.Equal(t, uint(0), policy1Team1.FailingHostCount)
countBiggerThanZero = true
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q,
&countBiggerThanZero,
`SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`,
policy1Team1.ID,
)
})
require.False(t, countBiggerThanZero)
// Back to (again) associating first script to policy1Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: script.ID},
},
}, http.StatusOK, &mtplr)
// Associate winScript to policy2Team1.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: winScript.ID},
},
}, http.StatusOK, &mtplr)
// We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the
// current user's "Authorization: Bearer " header.
// host1Team1 fails all policies on the first report.
// Failing policy1Team1 means a script run must be generated.
// Failing policy2Team1 should not trigger a script run because it has a PowerShell script attached to it (doesn't apply to macOS).
// Failing policy3Team1 should do nothing because it doesn't have any scripts associated to it.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
hostPendingScript, err := s.ds.IsExecutionPendingForHost(ctx, host1Team1.ID, script.ID)
require.NoError(t, err)
require.True(t, hostPendingScript)
// Request a manual script execution on the host for the same script, which should fail.
var scriptRunResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host1Team1.ID, ScriptID: &script.ID}, http.StatusConflict, &scriptRunResp)
// Submit same results as before, which should not trigger a script run because the policy is already failing.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host1Team1.ID, script.ID)
require.NoError(t, err)
require.True(t, hostPendingScript)
// Submit same results but policy1Team1 now passes,
// and then submit again but policy1Team1 fails.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(true),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host1Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host1Team1.ID, script.ID)
require.NoError(t, err)
require.True(t, hostPendingScript)
// host2Team1 is failing policy2Team1 (incompatible) and policy3Team1 (no script) policies; no scripts should be queued
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
policy2Team1.ID: ptr.Bool(false),
policy3Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host2Team1.ID, script.ID)
require.NoError(t, err)
require.False(t, hostPendingScript)
// Associate psScript to policy4Team2.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: psScript.ID},
},
}, http.StatusOK, &mtplr)
// host3Team2 reports a failing result for policy4Team2, which should trigger a script run.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
policy4Team2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
host3PendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, host3Team2.ID, false)
require.NoError(t, err)
require.Len(t, host3PendingScripts, 1)
host3executionID := host3PendingScripts[0].ExecutionID
// Dissociate policy4Team2 from script.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: 0},
},
}, http.StatusOK, &mtplr)
// host3Team2 reports a failing result for policy4Team2.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host3Team2,
map[uint]*bool{
policy4Team2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
// hostVanillaOsquery5Team1 sends policy results with failed policies with associated scripts.
// Fleet should not queue scripts for vanilla osquery hosts.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
hostVanillaOsquery5Team1,
map[uint]*bool{
policy1Team1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
hostPendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, hostVanillaOsquery5Team1.ID, false)
require.NoError(t, err)
require.Len(t, hostPendingScripts, 0)
// activity feed should show script run as pending, with "Fleet" as author, policy ID and name set in body
var listResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host3Team2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Nil(t, listResp.Activities[0].ActorEmail)
require.Equal(t, "Fleet", *listResp.Activities[0].ActorFullName)
require.Nil(t, listResp.Activities[0].ActorGravatar)
require.Equal(t, "ran_script", listResp.Activities[0].Type)
var activityJson map[string]interface{}
err = json.Unmarshal(*listResp.Activities[0].Details, &activityJson)
require.NoError(t, err)
require.Equal(t, float64(policy4Team2.ID), activityJson["policy_id"])
require.Equal(t, "policy4Team2", activityJson["policy_name"])
// post script result response
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host3Team2.OrbitNodeKey, host3executionID)),
http.StatusOK, &orbitPostScriptResp)
// activity feed should show script run as completed
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host3Team2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Equal(t, "", *listResp.Activities[0].ActorEmail) // actor email is blank rather than nil here 👀
require.Equal(t, "Fleet", *listResp.Activities[0].ActorFullName)
require.Nil(t, listResp.Activities[0].ActorGravatar)
require.Equal(t, "ran_script", listResp.Activities[0].Type)
err = json.Unmarshal(*listResp.Activities[0].Details, &activityJson)
require.NoError(t, err)
require.Equal(t, float64(policy4Team2.ID), activityJson["policy_id"])
require.Equal(t, "policy4Team2", activityJson["policy_name"])
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIdentifier() {
t := s.T()
ctx := context.Background()
// Create a host without a team
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
software := []fleet.Software{
{Name: "DummyApp", Version: "0.0.2", Source: "apps"},
}
// we must ingest the title with an empty bundle identifier for this
// test to be valid
require.Empty(t, software[0].BundleIdentifier)
_, err = s.ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now()))
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "dummy_installer.pkg",
Version: "0.0.2",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
}
func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() {
t := s.T()
// Fedora and RHEL have hosts.platform = 'rhel'.
host := createOrbitEnrolledHost(t, "rhel", "", s.ds)
// Upload an RPM package.
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
PostInstallScript: "post install script",
Filename: "ruby.rpm",
Title: "ruby",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "rpm_packages")
// Send a request to the host to install the RPM package.
var installSoftwareResp installSoftwareResponse
beforeInstallRequest := time.Now()
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &installSoftwareResp)
installUUID := getLatestSoftwareInstallExecID(t, s.ds, host.ID)
// Simulate host installing the RPM package.
beforeInstallResult := time.Now()
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "1",
"install_script_exit_code": 1,
"install_script_output": "failed"
}`, *host.OrbitNodeKey, installUUID)),
http.StatusNoContent,
)
var resp getSoftwareInstallResultsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &resp)
assert.Equal(t, host.ID, resp.Results.HostID)
assert.Equal(t, installUUID, resp.Results.InstallUUID)
assert.Equal(t, fleet.SoftwareInstallFailed, resp.Results.Status)
assert.NotNil(t, resp.Results.PreInstallQueryOutput)
assert.Equal(t, fleet.SoftwareInstallerQuerySuccessCopy, *resp.Results.PreInstallQueryOutput)
assert.NotNil(t, resp.Results.Output)
assert.Equal(t, fmt.Sprintf(fleet.SoftwareInstallerInstallFailCopy, "failed"), *resp.Results.Output)
assert.Empty(t, resp.Results.PostInstallScriptOutput)
assert.Less(t, beforeInstallRequest, resp.Results.CreatedAt)
assert.Greater(t, time.Now(), resp.Results.CreatedAt)
assert.NotNil(t, resp.Results.UpdatedAt)
assert.Less(t, beforeInstallResult, *resp.Results.UpdatedAt)
wantAct := fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
InstallUUID: installUUID,
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
}
func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
t := s.T()
ctx := context.Background()
installerBytes := []byte("abc")
// Mock server to serve the "installers"
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/badinstaller":
_, _ = w.Write([]byte("badinstaller"))
case "/timeout":
time.Sleep(3 * time.Second)
_, _ = w.Write([]byte("timeout"))
default:
_, _ = w.Write(installerBytes)
}
}))
defer installerServer.Close()
getSoftwareInstallerIDByMAppID := func(mappID uint) uint {
var id uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id, "SELECT id FROM software_installers WHERE fleet_maintained_app_id = ?", mappID)
})
return id
}
// Non-existent maintained app
s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", &addFleetMaintainedAppRequest{AppID: 1}, http.StatusNotFound)
// Insert the list of maintained apps
insertedApps := maintained_apps.SyncApps(t, s.ds)
expectedApps := insertedApps
h := sha256.New()
_, err := h.Write(installerBytes)
require.NoError(t, err)
spoofedSHA := 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: installerServer.URL + "/installer.zip",
InstallScriptRef: "foobaz",
UninstallScriptRef: "foobaz",
SHA256: spoofedSHA,
DefaultCategories: []string{"Productivity"},
})
manifest := ma.FMAManifestFile{
Versions: versions,
Refs: map[string]string{
"foobaz": "Hello World!",
},
}
switch slug {
case "fail":
w.WriteHeader(http.StatusInternalServerError)
return
case "notfound":
w.WriteHeader(http.StatusNotFound)
return
case "badinstaller":
manifest.Versions[0].InstallerURL = installerServer.URL + "/badinstaller"
case "timeout":
manifest.Versions[0].InstallerURL = installerServer.URL + "/timeout"
}
err := json.NewEncoder(w).Encode(manifest)
require.NoError(t, err)
}))
t.Cleanup(manifestServer.Close)
os.Setenv("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL)
defer os.Unsetenv("FLEET_DEV_MAINTAINED_APPS_BASE_URL")
// Create a team
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
// Check apps returned
var listMAResp listFleetMaintainedAppsResponse
s.DoJSON(http.MethodGet, "/api/latest/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsRequest{}, http.StatusOK,
&listMAResp, "team_id", fmt.Sprint(team.ID))
require.Nil(t, listMAResp.Err)
require.False(t, listMAResp.Meta.HasPreviousResults)
require.False(t, listMAResp.Meta.HasNextResults)
require.Len(t, listMAResp.FleetMaintainedApps, len(expectedApps))
slices.SortFunc(listMAResp.FleetMaintainedApps, func(a, b fleet.MaintainedApp) int {
return cmp.Compare(a.Name, b.Name)
})
slices.SortFunc(expectedApps, func(a, b fleet.MaintainedApp) int {
return cmp.Compare(a.Name, b.Name)
})
require.Equal(t, expectedApps, listMAResp.FleetMaintainedApps)
var listMAResp2 listFleetMaintainedAppsResponse
s.DoJSON(
http.MethodGet,
"/api/latest/fleet/software/fleet_maintained_apps",
listFleetMaintainedAppsRequest{},
http.StatusOK,
&listMAResp2,
"team_id", fmt.Sprint(team.ID),
"per_page", "2",
"page", "2",
)
require.Nil(t, listMAResp2.Err)
require.True(t, listMAResp2.Meta.HasPreviousResults)
require.True(t, listMAResp2.Meta.HasNextResults)
require.Len(t, listMAResp2.FleetMaintainedApps, 2)
require.Contains(t, listMAResp.FleetMaintainedApps, listMAResp2.FleetMaintainedApps[0])
// Check individual app fetch
var getMAResp getFleetMaintainedAppResponse
s.DoJSON(http.MethodGet, fmt.Sprintf("/api/latest/fleet/software/fleet_maintained_apps/%d", listMAResp.FleetMaintainedApps[0].ID), getFleetMaintainedAppRequest{}, http.StatusOK, &getMAResp)
// TODO this will change when actual install scripts are created.
dbAppRecord, err := s.ds.GetMaintainedAppByID(ctx, listMAResp.FleetMaintainedApps[0].ID, nil)
require.NoError(t, err)
_, err = maintained_apps.Hydrate(ctx, dbAppRecord)
require.NoError(t, err)
dbAppResponse := fleet.MaintainedApp{
ID: dbAppRecord.ID,
Name: dbAppRecord.Name,
Slug: dbAppRecord.Slug,
Version: dbAppRecord.Version,
Platform: dbAppRecord.Platform,
InstallerURL: dbAppRecord.InstallerURL,
InstallScript: dbAppRecord.InstallScript,
UninstallScript: dbAppRecord.UninstallScript,
Categories: []string{"Productivity"},
}
require.NotEmpty(t, getMAResp.FleetMaintainedApp.InstallerURL)
require.NotEmpty(t, getMAResp.FleetMaintainedApp.InstallScript)
require.NotEmpty(t, getMAResp.FleetMaintainedApp.UninstallScript)
require.Equal(t, dbAppResponse, *getMAResp.FleetMaintainedApp)
// Try adding ingested app with invalid secret
reqInvalidSecret := &addFleetMaintainedAppRequest{
AppID: 1,
TeamID: &team.ID,
SelfService: true,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo $FLEET_SECRET_INVALID1",
PostInstallScript: "echo done $FLEET_SECRET_INVALID2",
UninstallScript: "echo $FLEET_SECRET_INVALID3",
}
respBadSecret := s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", reqInvalidSecret, http.StatusUnprocessableEntity)
errNames, errReasons := extractServerErrorNameReasons(respBadSecret.Body)
assert.ElementsMatch(t, []string{"install script", "post-install script", "uninstall script"}, errNames)
assert.Len(t, errReasons, 3)
for _, reason := range errReasons {
assert.Contains(t, reason, "$FLEET_SECRET_INVALID")
}
reqInvalidSecret = &addFleetMaintainedAppRequest{
AppID: 1,
TeamID: &team.ID,
SelfService: true,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done $FLEET_SECRET_INVALID2",
UninstallScript: "echo",
}
respBadSecret = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", reqInvalidSecret, http.StatusUnprocessableEntity)
errNames, errReasons = extractServerErrorNameReasons(respBadSecret.Body)
assert.ElementsMatch(t, []string{"post-install script"}, errNames)
require.Len(t, errReasons, 1)
assert.Contains(t, errReasons[0], "$FLEET_SECRET_INVALID2")
reqInvalidSecret = &addFleetMaintainedAppRequest{
AppID: 1,
TeamID: &team.ID,
SelfService: true,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
UninstallScript: "echo $FLEET_SECRET_INVALID3",
}
respBadSecret = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", reqInvalidSecret, http.StatusUnprocessableEntity)
errNames, errReasons = extractServerErrorNameReasons(respBadSecret.Body)
assert.ElementsMatch(t, []string{"uninstall script"}, errNames)
require.Len(t, errReasons, 1)
assert.Contains(t, errReasons[0], "$FLEET_SECRET_INVALID3")
// Add an ingested app to the team
var addMAResp addFleetMaintainedAppResponse
req := &addFleetMaintainedAppRequest{
AppID: 1,
TeamID: &team.ID,
SelfService: true,
PreInstallQuery: "SELECT 1",
PostInstallScript: "echo done",
}
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.Nil(t, addMAResp.Err)
s.DoJSON(http.MethodGet, "/api/latest/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsRequest{}, http.StatusOK,
&listMAResp, "team_id", fmt.Sprint(team.ID))
require.Nil(t, listMAResp.Err)
require.False(t, listMAResp.Meta.HasPreviousResults)
require.Len(t, listMAResp.FleetMaintainedApps, len(expectedApps))
// Validate software installer fields
mapp, err := s.ds.GetMaintainedAppByID(ctx, 1, &team.ID)
require.NoError(t, err)
_, err = maintained_apps.Hydrate(ctx, mapp)
require.NoError(t, err)
i, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), getSoftwareInstallerIDByMAppID(1))
require.NoError(t, err)
require.Equal(t, mapp.TitleID, i.TitleID)
require.Equal(t, ptr.Uint(1), i.FleetMaintainedAppID)
require.Equal(t, mapp.SHA256, i.StorageID)
require.Equal(t, "darwin", i.Platform)
require.NotEmpty(t, i.InstallScriptContentID)
require.Equal(t, req.PreInstallQuery, i.PreInstallQuery)
install, err := s.ds.GetAnyScriptContents(ctx, i.InstallScriptContentID)
require.NoError(t, err)
require.Equal(t, mapp.InstallScript, string(install))
require.NotNil(t, i.PostInstallScriptContentID)
postinstall, err := s.ds.GetAnyScriptContents(ctx, *i.PostInstallScriptContentID)
require.NoError(t, err)
require.Equal(t, req.PostInstallScript, string(postinstall))
// The maintained app should now be in software titles
var resp listSoftwareTitlesResponse
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "1",
"order_key", "name",
"order_direction", "desc",
"available_for_install", "true",
"team_id", fmt.Sprintf("%d", team.ID),
)
require.Equal(t, 1, resp.Count)
title := resp.SoftwareTitles[0]
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, ptr.String(mapp.UniqueIdentifier), title.BundleIdentifier)
require.Equal(t, mapp.Version, title.SoftwarePackage.Version)
require.Equal(t, "installer.zip", title.SoftwarePackage.Name)
require.Equal(t, ptr.Bool(req.SelfService), title.SoftwarePackage.SelfService)
// Check activity
s.lastActivityOfTypeMatches(
fleet.ActivityTypeAddedSoftware{}.ActivityName(),
fmt.Sprintf(`{"software_title": "%[1]s", "software_package": "installer.zip", "team_name": "%s", "team_id": %d, "self_service": true, "software_title_id": %d}`, mapp.Name, team.Name, team.ID, title.ID),
0,
)
// Edit DB to point some apps at bad-installer/timeout slugs
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, "UPDATE fleet_maintained_apps SET slug = ? WHERE id = 2", "badinstaller")
require.NoError(t, err)
_, err = q.ExecContext(ctx, "UPDATE fleet_maintained_apps SET slug = ? WHERE id = 3", "timeout")
return err
})
// Should return an error; SHAs don't match up
r := s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", &addFleetMaintainedAppRequest{AppID: 2}, http.StatusInternalServerError)
require.Contains(t, extractServerErrorText(r.Body), "mismatch in maintained app SHA256 hash")
// Should timeout
os.Setenv("FLEET_DEV_MAINTAINED_APPS_INSTALLER_TIMEOUT", "1s")
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", &addFleetMaintainedAppRequest{AppID: 3}, http.StatusGatewayTimeout)
os.Unsetenv("FLEET_DEV_MAINTAINED_APPS_INSTALLER_TIMEOUT")
require.Contains(t, extractServerErrorText(r.Body), "Couldn't add. Request timeout. Please make sure your server and load balancer timeout is long enough.")
// Add a maintained app to no team
req = &addFleetMaintainedAppRequest{
AppID: 4,
SelfService: true,
PreInstallQuery: "SELECT 1",
PostInstallScript: "echo done",
}
addMAResp = addFleetMaintainedAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.Nil(t, addMAResp.Err)
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "1",
"order_key", "name",
"order_direction", "desc",
"available_for_install", "true",
"team_id", "0",
)
mapp, err = s.ds.GetMaintainedAppByID(ctx, 4, ptr.Uint(0))
require.NoError(t, err)
_, err = maintained_apps.Hydrate(ctx, mapp)
require.NoError(t, err)
require.Equal(t, 1, resp.Count)
title = resp.SoftwareTitles[0]
require.Equal(t, title.ID, *mapp.TitleID)
require.Equal(t, mapp.Version, title.SoftwarePackage.Version)
require.Equal(t, "installer.zip", title.SoftwarePackage.Name)
titleResponse := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", title.ID), nil, http.StatusOK, &titleResponse, "team_id", "0")
require.NotNil(t, titleResponse.SoftwareTitle.SoftwarePackage)
require.Equal(t, []string{"Productivity"}, titleResponse.SoftwareTitle.SoftwarePackage.Categories)
i, err = s.ds.GetSoftwareInstallerMetadataByID(context.Background(), getSoftwareInstallerIDByMAppID(4))
require.NoError(t, err)
require.Equal(t, ptr.Uint(4), i.FleetMaintainedAppID)
require.Equal(t, mapp.SHA256, i.StorageID)
require.NotEmpty(t, i.InstallScriptContentID)
require.Equal(t, req.PreInstallQuery, i.PreInstallQuery)
install, err = s.ds.GetAnyScriptContents(ctx, i.InstallScriptContentID)
require.NoError(t, err)
require.Equal(t, mapp.InstallScript, string(install))
require.NotNil(t, i.PostInstallScriptContentID)
postinstall, err = s.ds.GetAnyScriptContents(ctx, *i.PostInstallScriptContentID)
require.NoError(t, err)
require.Equal(t, req.PostInstallScript, string(postinstall))
// Add some categories and validate them
cat1, err := s.ds.NewSoftwareCategory(ctx, "test_category_1")
require.NoError(t, err)
cat2, err := s.ds.NewSoftwareCategory(ctx, "test_category_2")
require.NoError(t, err)
updatePayload := &fleet.UpdateSoftwareInstallerPayload{
TitleID: title.ID,
InstallerID: i.InstallerID,
InstallScript: ptr.String(mapp.InstallScript),
Version: title.SoftwarePackage.Version,
SelfService: ptr.Bool(true),
Categories: []string{cat1.Name, cat2.Name},
}
s.updateSoftwareInstaller(t, updatePayload, http.StatusOK, "")
titleResponse = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", title.ID), nil, http.StatusOK, &titleResponse, "team_id", "0")
require.NotNil(t, titleResponse.SoftwareTitle.SoftwarePackage)
require.Len(t, titleResponse.SoftwareTitle.SoftwarePackage.Categories, 2)
require.Contains(t, titleResponse.SoftwareTitle.SoftwarePackage.Categories, cat1.Name)
require.Contains(t, titleResponse.SoftwareTitle.SoftwarePackage.Categories, cat2.Name)
// ===========================================================================================
// Adding an automatically installed FMA
// ===========================================================================================
// Add another FMA
req = &addFleetMaintainedAppRequest{
AppID: 5,
SelfService: false,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
TeamID: ptr.Uint(0),
}
addMAResp = addFleetMaintainedAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.NoError(t, addMAResp.Err)
require.NotEmpty(t, addMAResp.SoftwareTitleID)
// Add the automatic install policy
tpParams := teamPolicyRequest{
Name: "[Install software]",
Query: "select * from osquery;",
Description: "Some description",
Platform: "darwin",
SoftwareTitleID: &addMAResp.SoftwareTitleID,
}
tpResp := teamPolicyResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &tpResp)
require.NotNil(t, tpResp.Policy)
require.NotEmpty(t, tpResp.Policy.ID)
// List software titles; we should see the policy on the software title object
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "2",
"order_key", "id",
"order_direction", "desc",
"available_for_install", "true",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 2)
// most recently added FMA should have 1 automatic install policy
st := resp.SoftwareTitles[0] // sorted by ID above
require.NotNil(t, st.SoftwarePackage)
require.Len(t, st.SoftwarePackage.AutomaticInstallPolicies, 1)
gotPolicy := st.SoftwarePackage.AutomaticInstallPolicies[0]
require.Equal(t, tpResp.Policy.Name, gotPolicy.Name)
require.Equal(t, tpResp.Policy.ID, gotPolicy.ID)
// check that we created an activity for the policy creation
noTeamID := int64(0)
wantAct := fleet.ActivityTypeCreatedPolicy{
ID: gotPolicy.ID,
Name: gotPolicy.Name,
TeamID: &noTeamID,
TeamName: nil,
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
// First FMA added doesn't have automatic install policies
st = resp.SoftwareTitles[1] // sorted by ID above
require.NotNil(t, st.SoftwarePackage)
require.Empty(t, st.SoftwarePackage.AutomaticInstallPolicies)
// Get the specific app that we set to be installed automatically
var titleResp getSoftwareTitleResponse
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID),
getSoftwareTitleRequest{},
http.StatusOK, &titleResp,
"team_id", "0",
)
require.NotNil(t, titleResp.SoftwareTitle)
swTitle := titleResp.SoftwareTitle
require.NotNil(t, swTitle.SoftwarePackage)
require.Len(t, swTitle.SoftwarePackage.AutomaticInstallPolicies, 1)
gotPolicy = swTitle.SoftwarePackage.AutomaticInstallPolicies[0]
require.Equal(t, tpResp.Policy.Name, gotPolicy.Name)
require.Equal(t, tpResp.Policy.ID, gotPolicy.ID)
// Policy should appear in the list of policies
var listPolResp listTeamPoliciesResponse
s.DoJSON(
"GET", "/api/latest/fleet/teams/0/policies",
listTeamPoliciesRequest{},
http.StatusOK, &listPolResp,
"page", "0",
)
require.Len(t, listPolResp.Policies, 1)
policies := listPolResp.Policies
require.Equal(t, tpResp.Policy.Name, policies[0].Name)
require.Equal(t, tpResp.Policy.ID, policies[0].ID)
require.Equal(t, tpResp.Policy.Description, policies[0].Description)
require.Equal(t, tpResp.Policy.Query, policies[0].Query)
require.Equal(t, "darwin", policies[0].Platform)
require.False(t, policies[0].Critical)
require.NotNil(t, policies[0].InstallSoftware)
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID)
// ===========================================================================================
// Adding label-scoped FMA
// ===========================================================================================
// Add some labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: t.Name() + "1",
Platform: "darwin",
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: t.Name() + "2",
Platform: "darwin",
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label
// Add another FMA
req = &addFleetMaintainedAppRequest{
AppID: 6,
SelfService: false,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
TeamID: ptr.Uint(0),
LabelsIncludeAny: []string{lbl1.Name, lbl2.Name},
}
addMAResp = addFleetMaintainedAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.NoError(t, addMAResp.Err)
require.NotEmpty(t, addMAResp.SoftwareTitleID)
// Get software title details
titleResp = getSoftwareTitleResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID),
getSoftwareTitleRequest{},
http.StatusOK, &titleResp,
"team_id", "0",
)
require.NotNil(t, titleResp.SoftwareTitle)
swTitle = titleResp.SoftwareTitle
require.NotNil(t, swTitle.SoftwarePackage)
require.Empty(t, swTitle.SoftwarePackage.LabelsExcludeAny)
require.Len(t, swTitle.SoftwarePackage.LabelsIncludeAny, 2)
gotNames := make(map[string]bool)
for _, lbl := range swTitle.SoftwarePackage.LabelsIncludeAny {
gotNames[lbl.LabelName] = true
}
require.True(t, gotNames[lbl1.Name])
require.True(t, gotNames[lbl2.Name])
// Can't set non-existent label
req = &addFleetMaintainedAppRequest{
AppID: 7,
SelfService: false,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
TeamID: ptr.Uint(0),
LabelsIncludeAny: []string{"no-such-label"},
}
addMAResp = addFleetMaintainedAppResponse{}
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), "some or all the labels provided don't exist")
// Can't set both labels_include_any and labels_exclude_any
req.LabelsIncludeAny = []string{lbl1.Name, lbl2.Name}
req.LabelsExcludeAny = []string{lbl1.Name}
addMAResp = addFleetMaintainedAppResponse{}
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included`)
}
func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
t := s.T()
res := s.Do("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
"mdm": { "windows_migration_enabled": true }
}`), http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM is not enabled")
}
func (s *integrationEnterpriseTestSuite) TestDeleteLabels() {
t := s.T()
// create a couple labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: "TestDeleteLabels1",
Platform: "darwin",
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label.ID
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: "TestDeleteLabels2",
Platform: "darwin",
Query: "SELECT 2",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label.ID
// create a software installer associated with the label
installer := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby.deb",
SelfService: false,
TeamID: nil,
LabelsIncludeAny: []string{"TestDeleteLabels1"},
}
s.uploadSoftwareInstaller(t, installer, http.StatusOK, "")
// try to delete the label associated with the installer
res := s.Do("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1), nil, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "foreign key constraint on labels: TestDeleteLabels1")
// try to delete a label that does not exist by id and name
var delLabelResp deleteLabelByIDResponse
s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1+1000), nil, http.StatusNotFound, &delLabelResp)
s.DoJSON("DELETE", "/api/v1/fleet/labels/no-such-label", nil, http.StatusNotFound, &delLabelResp)
// delete the unused label2
s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl2), nil, http.StatusOK, &delLabelResp)
}
func (s *integrationEnterpriseTestSuite) TestListHostSoftwareWithLabelScoping() {
ctx := context.Background()
t := s.T()
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
// Create software installers and corresponding host install requests.
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
PostInstallScript: "post install script",
Filename: "ruby.deb",
Title: "ruby",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
// create install request for the software and record a successful result
resp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &resp)
installUUID := getLatestSoftwareInstallExecID(t, s.ds, host.ID)
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "",
"install_script_exit_code": 0,
"install_script_output": "success"
}`, *host.OrbitNodeKey, installUUID)),
http.StatusNoContent)
// Software is now installed on the host. We should see it in the host software list
getHostSw := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")
// De-scope the software by adding an exclude any label that the host has.
// TODO(JVE): remove/update this once the API is in place
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(
ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
siID, labelID, exclude,
)
return err
})
}
var installerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
})
require.NotEmpty(t, installerID)
// create some labels and assign them to the host
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label1",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
lbl1 := labelResp.Label
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label2",
Query: "SELECT 1",
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
lbl2 := labelResp.Label
err := s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
updateInstallerLabel(installerID, lbl1.ID, true)
updateInstallerLabel(installerID, lbl2.ID, true)
// We should still see the software at this point, because we haven't uninstalled it yet
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 1)
// installer should be out of scope since the label is "exclude any"
scoped, err := s.ds.IsSoftwareInstallerLabelScoped(ctx, installerID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// host should be excluded from the software installer
hostsNotInScope, err := s.ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, installerID)
require.NoError(t, err)
require.Contains(t, hostsNotInScope, host.ID)
// uninstall the software
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", host.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 1)
// TODO this is a corner case where we have visibility on a descoped uninstall, but this will disappear
// once we complete the uninstall because at that point we'll be relying solely on inventory to determine software
// status, because we should be treating descoped installers as if they don't exist.
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSw.Software[0].Status)
require.Nil(t, getHostSw.Software[0].SoftwarePackage)
var uninstallExecutionID string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &uninstallExecutionID, `
SELECT execution_id
FROM host_software_installs
WHERE host_id = ? AND status='pending_uninstall'
`, host.ID)
})
// Host sends failed uninstall result
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "uninstall"}`, *host.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Now that the software is uninstalled, we should no longer see it in the host software list,
// because it is de-scoped via labels.
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software?available_for_install=true", host.ID), nil, http.StatusOK, &getHostSw)
require.Empty(t, getHostSw.Software)
}
func (s *integrationEnterpriseTestSuite) TestAutomaticPolicies() {
t := s.T()
ctx := context.Background()
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"})
require.NoError(t, err)
// Upload dummy_installer.pkg to team1 without automatic policy.
pkgPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some pkg install script",
Filename: "dummy_installer.pkg",
TeamID: &team1.ID,
}
s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "")
// Check no policies were created.
ts := listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 0)
require.Len(t, ts.InheritedPolicies, 0)
// Delete and try again with automatic policy turned on.
pkgTitleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps")
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", pkgTitleID), nil, http.StatusNoContent,
"team_id", fmt.Sprintf("%d", team1.ID))
// Upload dummy_installer.pkg to team1 with automatic policy.
pkgPayload = &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some pkg install script 2",
Filename: "dummy_installer.pkg",
TeamID: &team1.ID,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "")
pkgTitleID = getSoftwareTitleID(t, s.ds, "DummyApp", "apps")
respTitle := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d?team_id=%d", pkgTitleID, team1.ID), listSoftwareTitlesRequest{}, http.StatusOK, &respTitle)
require.NotNil(t, respTitle.SoftwareTitle)
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
require.Len(t, respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies, 1)
require.Equal(t, "[Install software] DummyApp (pkg)", respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies[0].Name)
// check that we created an activity for the policy creation
wantAct := fleet.ActivityTypeCreatedPolicy{
ID: respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies[0].ID,
Name: respTitle.SoftwareTitle.SoftwarePackage.AutomaticInstallPolicies[0].Name,
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
// Check a policy was created on team1.
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 1)
require.Len(t, ts.InheritedPolicies, 0)
require.Equal(t, "[Install software] DummyApp (pkg)", ts.Policies[0].Name)
// Upload dummy_installer.pkg to team2 with automatic policy.
pkgPayload = &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some pkg install script 3",
Filename: "dummy_installer.pkg",
TeamID: &team2.ID,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "")
// Check a policy was created on team2.
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team2.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 1)
require.Len(t, ts.InheritedPolicies, 0)
require.Equal(t, "[Install software] DummyApp (pkg)", ts.Policies[0].Name)
// Upload ruby.deb to team1 with automatic policy.
payloadRubyDEB := &fleet.UploadSoftwareInstallerPayload{
Filename: "ruby.deb",
TeamID: &team1.ID,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, payloadRubyDEB, http.StatusOK, "")
payloadRubyRPM := &fleet.UploadSoftwareInstallerPayload{
Filename: "ruby.rpm",
TeamID: &team1.ID,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, payloadRubyRPM, http.StatusOK, "")
// Upload fleet-osquery.msi to team1 with automatic policy.
fleetOsqueryPayload := &fleet.UploadSoftwareInstallerPayload{
Filename: "fleet-osquery.msi",
TeamID: &team1.ID,
AutomaticInstall: true,
}
s.uploadSoftwareInstaller(t, fleetOsqueryPayload, http.StatusOK, "")
// Check policies were created on team1.
ts = listTeamPoliciesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts)
require.Len(t, ts.Policies, 4)
require.Len(t, ts.InheritedPolicies, 0)
require.Equal(t, "[Install software] ruby (deb)", ts.Policies[1].Name)
require.Equal(t, "[Install software] ruby (rpm)", ts.Policies[2].Name)
require.Equal(t, "[Install software] Fleet osquery (msi)", ts.Policies[3].Name)
}
// test for #26668
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerOrbitDownloadFailure() {
t := s.T()
// upload an installer
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
PostInstallScript: "post install script",
Filename: "ruby.deb",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages")
// create an orbit host
host := createOrbitEnrolledHost(t, "linux", "orbit-host", s.ds)
// create a software installation request, is immediately activated
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), installSoftwareRequest{}, http.StatusAccepted)
// should be listed in upcoming activities
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
swInstallExecID := listUpcomingAct.Activities[0].UUID
// add a script execution request, not activated yet
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run",
fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo 'hello'"}, http.StatusAccepted, &runResp)
require.Equal(t, host.ID, runResp.HostID)
require.NotEmpty(t, runResp.ExecutionID)
// software install exec ID is returned in orbit notifications, but not script exec
var orbitResp orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitResp)
require.Equal(t, []string{swInstallExecID}, orbitResp.Notifications.PendingSoftwareInstallerIDs)
require.Empty(t, orbitResp.Notifications.PendingScriptExecutionIDs)
// simulate a failed orbit download of the installer - it sends an empty
// result (i.e. no result)
s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{
OrbitNodeKey: *host.OrbitNodeKey,
HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{
InstallUUID: swInstallExecID,
},
}, http.StatusNoContent)
// software install exec ID is still returned in orbit notifications, but not script exec
orbitResp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitResp)
require.Equal(t, []string{swInstallExecID}, orbitResp.Notifications.PendingSoftwareInstallerIDs)
require.Empty(t, orbitResp.Notifications.PendingScriptExecutionIDs)
// installer is still listed as first upcoming activity
listUpcomingAct = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 2)
require.Equal(t, swInstallExecID, listUpcomingAct.Activities[0].UUID)
scriptExecID := listUpcomingAct.Activities[1].UUID
// record an actual result for the installer
s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{
OrbitNodeKey: *host.OrbitNodeKey,
HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{
InstallUUID: swInstallExecID,
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("hello"),
},
}, http.StatusNoContent)
// now the script exec ID is returned in notifications
orbitResp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitResp)
require.Equal(t, []string{scriptExecID}, orbitResp.Notifications.PendingScriptExecutionIDs)
require.Empty(t, orbitResp.Notifications.PendingSoftwareInstallerIDs)
// only script is now returned in upcoming
listUpcomingAct = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
require.Equal(t, scriptExecID, listUpcomingAct.Activities[0].UUID)
// past activity is created for the software install
wantAct := fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: "ruby",
SoftwarePackage: payload.Filename,
InstallUUID: swInstallExecID,
Status: string(fleet.SoftwareInstalled),
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
}
func (s *integrationEnterpriseTestSuite) TestBatchSoftwareUploadWithSHAs() {
t := s.T()
ctx := context.Background()
// create a team
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
// create an HTTP server to host the software installer
var hitDebURL, hitExeURL, hitPkgURL bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ruby.deb", "/updated/ruby.deb":
hitDebURL = true
file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb"))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
_, err = io.Copy(w, file)
require.NoError(t, err)
case "/app.exe":
hitExeURL = true
file, err := os.Open(filepath.Join("..", "..", "pkg", "file", "testdata", "software-installers", "hello-world-installer.exe"))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/vnd.microsoft.portable-executable")
_, err = io.Copy(w, file)
require.NoError(t, err)
case "/app.pkg":
hitPkgURL = true
file, err := os.Open(filepath.Join("testdata", "software-installers", "dummy_installer.pkg"))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/x-newton-compatible-pkg")
_, err = io.Copy(w, file)
require.NoError(t, err)
default:
w.WriteHeader(http.StatusNotFound)
return
}
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
// do a request with a valid URL, but invalid hash, should fail
rubyURL := srv.URL + "/ruby.deb"
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, SHA256: "foobar"},
}
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
errMsg := waitBatchSetSoftwareInstallersFailed(t, s, team1.Name, batchResponse.RequestUUID)
require.Equal(t, fmt.Sprintf("downloaded installer hash does not match provided hash for installer with url %s", rubyURL), errMsg)
// payload without URL or hash should fail
softwareToInstall[0].SHA256 = ""
softwareToInstall[0].URL = ""
resp := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", team1.Name)
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't edit software. One or more software packages is missing url or hash_sha256 fields.")
// Pass in valid URL
softwareToInstall[0].URL = rubyURL
softwareToInstall[0].SHA256 = ""
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
packages := waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team1.ID, *packages[0].TeamID)
require.True(t, hitDebURL)
hitDebURL = false
var debHash string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &debHash, "SELECT storage_id FROM software_installers WHERE title_id = ?", packages[0].TitleID)
})
require.NotEmpty(t, debHash)
// add the hash to the payload
softwareToInstall[0].SHA256 = debHash
// dry run shouldn't hit the download endpoint since we included the SHA
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name, "dry_run", "true")
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team1.ID, *packages[0].TeamID)
require.False(t, hitDebURL)
// since we provided the SHA and we'd already added the installer, we should not hit the
// download endpoint
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team1.ID, *packages[0].TeamID)
require.False(t, hitDebURL)
// check that URL comes back on individual title fetch
stResp := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *packages[0].TitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team1.ID))
require.NotNil(t, stResp.SoftwareTitle.SoftwarePackage)
require.Equal(t, "ruby", stResp.SoftwareTitle.Name)
require.Equal(t, rubyURL, stResp.SoftwareTitle.SoftwarePackage.URL)
// create a new team
team2, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name() + "2",
Description: "desc",
})
require.NoError(t, err)
// create a new user for team 2; doesn't have access to team 1
team2Admin := &fleet.User{
Name: "Team 2 Admin",
Email: uuid.NewString() + "@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team2,
Role: fleet.RoleAdmin,
},
},
}
require.NoError(t, team2Admin.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(context.Background(), team2Admin)
require.NoError(t, err)
s.setTokenForTest(t, team2Admin.Email, test.GoodPassword)
// make sure to not break other tests
defer func() { s.token = s.getTestAdminToken() }()
// Remove the URL; since the user doesn't have access to team 1 and only the hash is provided,
// this should fail
softwareToInstall[0].URL = ""
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, "package not found with hash")
// user doesn't have access to team1: we should hit the download endpoint
softwareToInstall[0].URL = rubyURL
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name, "dry_run", "true")
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Empty(t, packages)
require.True(t, hitDebURL)
hitDebURL = false
// same for real run
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team2.ID, *packages[0].TeamID)
require.True(t, hitDebURL)
hitDebURL = false
// Send payload with just the SHA; should succeed and not hit the download endpoint because we
// downloaded it just above
softwareToInstall[0].URL = ""
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Empty(t, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team2.ID, *packages[0].TeamID)
require.False(t, hitDebURL)
// Update the URL, should re-download
updatedRubyURL := srv.URL + "/updated/ruby.deb"
softwareToInstall[0].URL = updatedRubyURL
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, updatedRubyURL, packages[0].URL)
require.NotNil(t, packages[0].TeamID)
require.Equal(t, team2.ID, *packages[0].TeamID)
require.True(t, hitDebURL)
hitDebURL = false
// add exe to payloads
exeURL := srv.URL + "/app.exe"
softwareToInstall = append(softwareToInstall, &fleet.SoftwareInstallerPayload{
URL: exeURL,
})
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, "Couldn't add. Install script is required for .exe packages.")
softwareToInstall[1].InstallScript = "echo install"
softwareToInstall[1].UninstallScript = "echo uninstall"
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 2)
require.True(t, hitExeURL)
hitExeURL = false
var exeHash string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &exeHash, "SELECT storage_id FROM software_installers WHERE title_id = ?", packages[1].TitleID)
})
require.NotEmpty(t, exeHash)
// remove the URL and scripts, and add the hash. we should get an error because scripts are required.
softwareToInstall[1].InstallScript = ""
softwareToInstall[1].UninstallScript = ""
softwareToInstall[1].URL = ""
softwareToInstall[1].SHA256 = exeHash
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, "Couldn't edit. Install script is required for .exe packages.")
require.False(t, hitExeURL)
// add the install script, but without uninstall script we should still fail
softwareToInstall[1].InstallScript = "echo install"
softwareToInstall[1].UninstallScript = ""
softwareToInstall[1].URL = ""
softwareToInstall[1].SHA256 = exeHash
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, "Couldn't edit. Uninstall script is required for .exe packages.")
require.False(t, hitExeURL)
// add both scripts to get a success
softwareToInstall[1].InstallScript = "echo install 2"
softwareToInstall[1].UninstallScript = "echo uninstall 2"
// add the pkg installer with some custom scripts
pkgURL := srv.URL + "/app.pkg"
softwareToInstall = append(softwareToInstall, &fleet.SoftwareInstallerPayload{
URL: pkgURL,
InstallScript: "some install script",
UninstallScript: "some uninstall script",
})
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 3)
pkgTitleID := packages[2].TitleID
require.NotNil(t, pkgTitleID)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *pkgTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team2.ID))
require.NotNil(t, stResp.SoftwareTitle.SoftwarePackage)
require.Equal(t, "DummyApp", stResp.SoftwareTitle.Name)
require.Equal(t, pkgURL, stResp.SoftwareTitle.SoftwarePackage.URL)
require.Equal(t, softwareToInstall[2].InstallScript, stResp.SoftwareTitle.SoftwarePackage.InstallScript)
require.Equal(t, softwareToInstall[2].UninstallScript, stResp.SoftwareTitle.SoftwarePackage.UninstallScript)
require.False(t, hitDebURL)
require.False(t, hitExeURL)
require.True(t, hitPkgURL)
hitPkgURL = false
// Attempt to add the exe to team 1 without scripts. Even though the admin has access to
// both teams, it should fail because scripts are required for exes.
// Check without either script first.
s.token = s.getTestAdminToken()
softwareToInstall[1].InstallScript = ""
softwareToInstall[1].UninstallScript = ""
softwareToInstall[1].URL = ""
softwareToInstall[1].SHA256 = exeHash
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall[:2]}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, "Couldn't edit. Install script is required for .exe packages.")
require.False(t, hitExeURL)
// Now add just an install script, should still fail.
softwareToInstall[1].InstallScript = "echo foo"
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall[:2]}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, "Couldn't edit. Uninstall script is required for .exe packages.")
require.False(t, hitExeURL)
// add the scripts back to not break subsequent calls
softwareToInstall[1].InstallScript = "echo install 2"
softwareToInstall[1].UninstallScript = "echo uninstall 2"
expectedUninstallScript := `#!/bin/sh
# Fleet extracts and saves package IDs.
pkg_ids=(
"com.example.dummy"
)
# For each package id, get all .app folders associated with the package and remove them.
for pkg_id in "${pkg_ids[@]}"
do
# Get volume and location of the package.
volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{if (NF>1) print $NF}')
location=$(pkgutil --pkg-info "$pkg_id" | grep -i "location" | awk '{if (NF>1) print $NF}')
# Check if this package id corresponds to a valid/installed package
if [[ ! -z "$volume" ]]; then
# Remove individual directories that end with ".app" belonging to the package.
# Only process directories that end with ".app" to prevent Fleet from removing top level directories.
pkgutil --only-dirs --files "$pkg_id" | grep "\.app$" | sed -e 's@^@'"$volume""$location"'/@' | tr '\n' '\0' | xargs -n 1 -0 rm -rf
# Remove receipts
pkgutil --forget "$pkg_id"
else
echo "WARNING: volume is empty for package ID $pkg_id"
fi
done
`
// remove the custom scripts from the .pkg. We should get back the auto-generated ones.
softwareToInstall[2].InstallScript = ""
softwareToInstall[2].UninstallScript = ""
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 3)
pkgTitleID = packages[2].TitleID
require.NotNil(t, pkgTitleID)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *pkgTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team2.ID))
require.NotNil(t, stResp.SoftwareTitle.SoftwarePackage)
require.Equal(t, "DummyApp", stResp.SoftwareTitle.Name)
require.Equal(t, pkgURL, stResp.SoftwareTitle.SoftwarePackage.URL)
require.Equal(t, file.GetInstallScript("pkg"), stResp.SoftwareTitle.SoftwarePackage.InstallScript)
require.Equal(t, expectedUninstallScript, stResp.SoftwareTitle.SoftwarePackage.UninstallScript)
// Clean up all installers from the store. We don't care about how many installers are
// removed in this test, so just check that the cleanup succeeded.
_, err = s.softwareInstallStore.Cleanup(ctx, nil, time.Now())
require.NoError(t, err)
// At this point, the exe payload has no URL. Batch set should fail because the
// installer bytes don't exist anymore and there is no URL provided.
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
errMsg = waitBatchSetSoftwareInstallersFailed(t, s, team2.Name, batchResponse.RequestUUID)
require.Contains(t, errMsg, fmt.Sprintf("package not found with hash %s", exeHash))
// Add the URL back and do the batch set. Should succeed and re-download all installers.
softwareToInstall[1].URL = exeURL
hitDebURL, hitExeURL, hitPkgURL = false, false, false
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID)
require.Len(t, packages, 3)
for _, v := range []bool{hitDebURL, hitExeURL, hitPkgURL} {
require.True(t, v)
}
}
func (s *integrationEnterpriseTestSuite) TestSSOIdPInitiatedLogin() {
t := s.T()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {
"server_url": "https://localhost:8080"
},
"sso_settings": {
"enable_sso": true,
"enable_jit_provisioning": true,
"enable_sso_idp_login": true,
"entity_id": "sso.test.com",
"idp_name": "SimpleSAML",
"metadata_url": "http://127.0.0.1:9080/simplesaml/saml2/idp/metadata.php"
}
}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
body := s.LoginSSOUserIDPInitiated("sso_user2", "user123#", "sso.test.com")
require.Contains(t, body, "Redirecting to Fleet at / ...")
}
func (s *integrationEnterpriseTestSuite) TestBatchSoftwareInstallerAndFMACategories() {
t := s.T()
ctx := context.Background()
// create a team
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
token := "good_token"
host := createOrbitEnrolledHost(t, "darwin", "host1", s.ds)
createDeviceTokenForHost(t, s.ds, host.ID, token)
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &team1.ID, HostIDs: []uint{host.ID}}, http.StatusOK, &addHostsToTeamResponse{})
// create an HTTP server to host the software installer
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/dummy_installer.pkg":
file, err := os.Open(filepath.Join("testdata", "software-installers", "dummy_installer.pkg"))
require.NoError(t, err)
defer file.Close()
w.Header().Set("Content-Type", "application/application/x-newton-compatible-pkg")
_, err = io.Copy(w, file)
require.NoError(t, err)
default:
w.WriteHeader(http.StatusNotFound)
return
}
})
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
maintained1, err := s.ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "1Password",
Slug: "1password/darwin",
Platform: "darwin",
UniqueIdentifier: "com.1password.1password",
})
require.NoError(t, err)
// do a request with an invalid category
pkgURL := srv.URL + "/dummy_installer.pkg"
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{URL: pkgURL, Categories: []string{"Not Found"}, SelfService: true},
{Slug: &maintained1.Slug, SelfService: true},
}
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
errMsg := waitBatchSetSoftwareInstallersFailed(t, s, team1.Name, batchResponse.RequestUUID)
require.Equal(t, "some or all of the categories provided don't exist", errMsg)
testCases := []struct {
desc string
categories []string
fmaDefaultCategories []string
}{
{
desc: "duplicate categories provided",
categories: []string{"Developer tools", "Browsers", "Browsers"},
},
{
desc: "valid categories 1",
categories: []string{"Developer tools", "Browsers"},
},
{
desc: "valid categories 2",
categories: []string{"Communication", "Productivity"},
},
{
desc: "empty categories",
fmaDefaultCategories: []string{"Productivity"},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
softwareToInstall[0].Categories = tc.categories
softwareToInstall[1].Categories = tc.categories
// remove duplicates if any
tc.categories = server.RemoveDuplicatesFromSlice(tc.categories)
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
packages := waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 2)
for _, p := range packages {
require.NotNil(t, p.TitleID)
require.NotNil(t, p.TeamID)
require.Equal(t, team1.ID, *p.TeamID)
// check that categories come back on individual title fetch
stResp := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *p.TitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team1.ID))
require.NotNil(t, stResp.SoftwareTitle.SoftwarePackage)
if stResp.SoftwareTitle.SoftwarePackage.FleetMaintainedAppID != nil && len(tc.categories) == 0 {
// if no categories are set on an FMA in GitOps, we set categories to
// default values
require.ElementsMatch(t, tc.fmaDefaultCategories, stResp.SoftwareTitle.SoftwarePackage.Categories)
continue
}
require.ElementsMatch(t, tc.categories, stResp.SoftwareTitle.SoftwarePackage.Categories)
}
// check that the categories come back on the My device page
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw := getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 2)
for _, s := range getDeviceSw.Software {
if s.Name == maintained1.Name && len(tc.categories) == 0 {
// if no categories are set on an FMA in GitOps, we set categories to
// default values
require.ElementsMatch(t, tc.fmaDefaultCategories, s.SoftwarePackage.Categories)
continue
}
require.ElementsMatch(t, tc.categories, s.SoftwarePackage.Categories)
}
})
}
}
type mockedConditionalAccessMicrosoftProxy struct {
createResponse *conditional_access_microsoft_proxy.CreateResponse
getResponse *conditional_access_microsoft_proxy.GetResponse
deleteErr error
deleteResponse *conditional_access_microsoft_proxy.DeleteResponse
setComplianceStatusFunc func(
ctx context.Context,
tenantID string, secret string,
deviceID string,
userPrincipalName string,
mdmEnrolled bool,
deviceName, osName, osVersion string,
compliant bool,
lastCheckInTime time.Time,
) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error)
getMessageStatusFunc func(
ctx context.Context,
tenantID string,
secret string,
messageID string,
) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error)
}
func (m *mockedConditionalAccessMicrosoftProxy) Create(ctx context.Context, tenantID string) (*conditional_access_microsoft_proxy.CreateResponse, error) {
return m.createResponse, nil
}
func (m *mockedConditionalAccessMicrosoftProxy) Get(ctx context.Context, tenantID string, secret string) (*conditional_access_microsoft_proxy.GetResponse, error) {
return m.getResponse, nil
}
func (m *mockedConditionalAccessMicrosoftProxy) Delete(ctx context.Context, tenantID string, secret string) (*conditional_access_microsoft_proxy.DeleteResponse, error) {
if m.deleteErr != nil {
return nil, m.deleteErr
}
return m.deleteResponse, nil
}
func (m *mockedConditionalAccessMicrosoftProxy) SetComplianceStatus(
ctx context.Context,
tenantID string, secret string,
deviceID string,
userPrincipalName string,
mdmEnrolled bool,
deviceName, osName, osVersion string,
compliant bool,
lastCheckInTime time.Time,
) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error) {
return m.setComplianceStatusFunc(ctx, tenantID, secret, deviceID, userPrincipalName, mdmEnrolled, deviceName, osName, osVersion, compliant, lastCheckInTime)
}
func (m *mockedConditionalAccessMicrosoftProxy) GetMessageStatus(
ctx context.Context, tenantID string, secret string, messageID string,
) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error) {
return m.getMessageStatusFunc(ctx, tenantID, secret, messageID)
}
var mockedConditionalAccessMicrosoftProxyInstance = &mockedConditionalAccessMicrosoftProxy{}
func (s *integrationEnterpriseTestSuite) TestConditionalAccessBasicSetup() {
t := s.T()
// Test license.managed_cloud is set on Cloud environments.
var acResp appConfigResponse
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.True(t, acResp.License.ManagedCloud)
// Test global maintainer fails to create the integration.
u := &fleet.User{
Name: "test maintainer",
Email: "maintainer@example.com",
GlobalRole: ptr.String(fleet.RoleMaintainer),
}
password := test.GoodPassword
require.NoError(t, u.SetPassword(password, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.token = s.getTestToken("maintainer@example.com", password)
var r conditionalAccessMicrosoftCreateResponse
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{}, http.StatusForbidden, &r)
var c conditionalAccessMicrosoftConfirmResponse
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusForbidden, &c)
var d conditionalAccessMicrosoftDeleteResponse
s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusForbidden, &d)
// Restore token for global admin.
s.token = s.getTestAdminToken()
// Setup integration.
mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{
TenantID: "foobar",
SetupDone: false,
AdminConsentURL: "https://example.com",
}
mockedConditionalAccessMicrosoftProxyInstance.createResponse = &conditional_access_microsoft_proxy.CreateResponse{
TenantID: "foobar",
Secret: "secret",
}
r = conditionalAccessMicrosoftCreateResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{
MicrosoftTenantID: "foobar",
}, http.StatusOK, &r)
require.Equal(t, "https://example.com", r.MicrosoftAuthenticationURL)
// UI uses the /config endpoint to know the status of the integration.
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.NotNil(t, acResp.ConditionalAccess)
require.Equal(t, "foobar", acResp.ConditionalAccess.MicrosoftEntraTenantID)
require.False(t, acResp.ConditionalAccess.MicrosoftEntraConnectionConfigured)
// Confirm should return that the setup is not done.
c = conditionalAccessMicrosoftConfirmResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c)
require.False(t, c.ConfigurationCompleted)
// Confirm now should succeed.
mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{
TenantID: "foobar",
SetupDone: true,
}
c = conditionalAccessMicrosoftConfirmResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c)
require.True(t, c.ConfigurationCompleted)
// Confirm again should succeed because integration is done.
c = conditionalAccessMicrosoftConfirmResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c)
require.True(t, c.ConfigurationCompleted)
// Create will succeed if using the same tenant ID.
r = conditionalAccessMicrosoftCreateResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{
MicrosoftTenantID: "foobar",
}, http.StatusOK, &r)
// Create will should fail if using the a different tenant ID (if the setup is done).
r = conditionalAccessMicrosoftCreateResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{
MicrosoftTenantID: "zoobar",
}, http.StatusBadRequest, &r)
// Test app config returns that the configuration is done.
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.NotNil(t, acResp.ConditionalAccess)
require.Equal(t, "foobar", acResp.ConditionalAccess.MicrosoftEntraTenantID)
require.True(t, acResp.ConditionalAccess.MicrosoftEntraConnectionConfigured)
// Delete endpoint.
mockedConditionalAccessMicrosoftProxyInstance.deleteResponse = &conditional_access_microsoft_proxy.DeleteResponse{}
d = conditionalAccessMicrosoftDeleteResponse{}
s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusOK, &d)
// Deleting again should fail with bad request.
d = conditionalAccessMicrosoftDeleteResponse{}
s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusBadRequest, &d)
// Test app config returns that the integration was deleted.
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Nil(t, acResp.ConditionalAccess)
// Create again with a different tenant.
mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{
TenantID: "zoobar",
SetupDone: false,
AdminConsentURL: "https://example.com",
}
mockedConditionalAccessMicrosoftProxyInstance.createResponse = &conditional_access_microsoft_proxy.CreateResponse{
TenantID: "zoobar",
Secret: "secret",
}
r = conditionalAccessMicrosoftCreateResponse{}
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{
MicrosoftTenantID: "zoobar",
}, http.StatusOK, &r)
// Test app config returns that the new integration was created.
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.NotNil(t, acResp.ConditionalAccess)
require.Equal(t, "zoobar", acResp.ConditionalAccess.MicrosoftEntraTenantID)
require.False(t, acResp.ConditionalAccess.MicrosoftEntraConnectionConfigured)
// Simulate a not found error on the proxy (should allow deletion to start over).
mockedConditionalAccessMicrosoftProxyInstance.deleteErr = ¬FoundError{}
d = conditionalAccessMicrosoftDeleteResponse{}
s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusOK, &d)
// Test app config returns that the configuration is gone.
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Nil(t, acResp.ConditionalAccess)
}
func (s *integrationEnterpriseTestSuite) TestConditionalAccessPolicies() {
t := s.T()
// Setup integration.
mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{
TenantID: "foobar",
SetupDone: false,
AdminConsentURL: "https://example.com",
}
mockedConditionalAccessMicrosoftProxyInstance.createResponse = &conditional_access_microsoft_proxy.CreateResponse{
TenantID: "foobar",
Secret: "secret",
}
var r conditionalAccessMicrosoftCreateResponse
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{
MicrosoftTenantID: "foobar",
}, http.StatusOK, &r)
mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{
TenantID: "foobar",
SetupDone: true,
}
var c conditionalAccessMicrosoftConfirmResponse
s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c)
require.True(t, c.ConfigurationCompleted)
t1, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team1",
Description: "desc team1",
})
require.NoError(t, err)
var pr teamPolicyResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", t1.ID), teamPolicyRequest{
Query: "SELECT 1;",
Name: "Compliance check 1",
ConditionalAccessEnabled: true,
}, http.StatusOK, &pr)
cp1 := pr.Policy
pr = teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", t1.ID), teamPolicyRequest{
Query: "SELECT 2;",
Name: "Compliance check 2",
ConditionalAccessEnabled: true,
}, http.StatusOK, &pr)
cp2 := pr.Policy
pr = teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", t1.ID), teamPolicyRequest{
Query: "SELECT 3;",
Name: "Other policy",
ConditionalAccessEnabled: false,
}, http.StatusOK, &pr)
p3 := pr.Policy
ctx := context.Background()
newHost := func(name string, teamID *uint) *fleet.Host {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + name),
NodeKey: ptr.String(t.Name() + name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
return h
}
//
// Test A: host h1 in team t1.
//
h1 := newHost("h1", &t1.ID)
orbitKey := setOrbitEnrollment(t, h1, s.ds)
h1.OrbitNodeKey = &orbitKey
// Feature is disabled on the host's team.
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h1, "entraDeviceID", "entraUserPrincipalName"), http.StatusOK, &distributedResp)
_, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
// Enable feature on team.
var tmResp teamResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", t1.ID), map[string]any{
"integrations": map[string]any{
"conditional_access_enabled": true,
},
}, http.StatusOK, &tmResp)
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h1, "entraDeviceID", "entraUserPrincipalName"), http.StatusOK, &distributedResp)
h1s, err := s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID", h1s.DeviceID)
require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName)
require.Nil(t, h1s.Managed)
require.Nil(t, h1s.Compliant)
// override value to reduce test time.
conditionalAccessSetWaitTime = 250 * time.Millisecond
mockedConditionalAccessMicrosoftProxyInstance.setComplianceStatusFunc = func(
ctx context.Context,
tenantID string, secret string,
deviceID string,
userPrincipalName string,
mdmEnrolled bool,
deviceName, osName, osVersion string,
compliant bool,
lastCheckInTime time.Time,
) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error) {
return &conditional_access_microsoft_proxy.SetComplianceStatusResponse{
MessageID: "messageID",
}, nil
}
setDone := make(chan struct{})
mockedConditionalAccessMicrosoftProxyInstance.getMessageStatusFunc = func(
ctx context.Context,
tenantID string,
secret string,
messageID string,
) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error) {
close(setDone)
return &conditional_access_microsoft_proxy.GetMessageStatusResponse{
MessageID: "messageID",
Status: "Completed",
}, nil
}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
h1,
map[uint]*bool{
cp1.ID: ptr.Bool(true),
cp2.ID: ptr.Bool(true),
p3.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
select {
case <-setDone:
time.Sleep(1 * time.Second)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for compliance to be set")
}
h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID", h1s.DeviceID)
require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName)
require.NotNil(t, h1s.Managed)
require.False(t, *h1s.Managed) // not MDM enrolled
require.NotNil(t, h1s.Compliant)
require.True(t, *h1s.Compliant) // the two configured policies are passing
// Enroll to MDM to update managed status.
err = s.ds.SetOrUpdateMDMData(ctx,
h1.ID, false, true /* enrolled */, s.server.URL, false, /* installedFromDEP */
"Fleet" /* MDM name */, "" /* fleetEnrollmentRef */, false, /* isPersonalEnrollment */
)
require.NoError(t, err)
setDone = make(chan struct{})
// Publish same policy results.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
h1,
map[uint]*bool{
cp1.ID: ptr.Bool(true),
cp2.ID: ptr.Bool(true),
p3.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
select {
case <-setDone:
time.Sleep(1 * time.Second)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for compliance to be set")
}
h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID", h1s.DeviceID)
require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName)
require.NotNil(t, h1s.Managed)
require.True(t, *h1s.Managed) // now should be MDM enrolled
require.NotNil(t, h1s.Compliant)
require.True(t, *h1s.Compliant) // the two configured policies are passing
setDone = make(chan struct{})
// Now the host is failing a compliance policy.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
h1,
map[uint]*bool{
cp1.ID: ptr.Bool(true),
cp2.ID: ptr.Bool(false),
p3.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
select {
case <-setDone:
time.Sleep(1 * time.Second)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for compliance to be set")
}
h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID", h1s.DeviceID)
require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName)
require.NotNil(t, h1s.Managed)
require.True(t, *h1s.Managed)
require.NotNil(t, h1s.Compliant)
require.False(t, *h1s.Compliant) // now the host is non-compliant
// Now nothing changes so there's no compliance operation on the proxy.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
h1,
map[uint]*bool{
cp1.ID: ptr.Bool(true),
cp2.ID: ptr.Bool(false),
p3.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
time.Sleep(5 * time.Second)
h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID", h1s.DeviceID)
require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName)
require.NotNil(t, h1s.Managed)
require.True(t, *h1s.Managed)
require.NotNil(t, h1s.Compliant)
require.False(t, *h1s.Compliant)
//
// Test B: host h2 in "No team".
//
h2 := newHost("h2", nil)
orbitKey2 := setOrbitEnrollment(t, h2, s.ds)
h2.OrbitNodeKey = &orbitKey2
// "No team" configuration for conditional access is in global config.
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{
"integrations": {
"conditional_access_enabled": true
}
}`), http.StatusOK)
// Test that by not setting it it's not disabled.
s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{
"integrations": {}
}`), http.StatusOK)
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.True(t, acResp.Integrations.ConditionalAccessEnabled.Set)
require.True(t, acResp.Integrations.ConditionalAccessEnabled.Value)
pr = teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", fleet.PolicyNoTeamID), teamPolicyRequest{
Query: "SELECT 1;",
Name: "Compliance check 1",
ConditionalAccessEnabled: true,
}, http.StatusOK, &pr)
cp1 = pr.Policy
pr = teamPolicyResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", fleet.PolicyNoTeamID), teamPolicyRequest{
Query: "SELECT 2;",
Name: "Other 1",
ConditionalAccessEnabled: false,
}, http.StatusOK, &pr)
p2 := pr.Policy
pr = teamPolicyResponse{}
// Enroll to MDM to update managed status.
err = s.ds.SetOrUpdateMDMData(ctx,
h2.ID, false, true /* enrolled */, s.server.URL, false, /* installedFromDEP */
"Fleet" /* MDM name */, "" /* fleetEnrollmentRef */, false, /* isPersonalEnrollment */
)
require.NoError(t, err)
// Ingest device ID and user principal name.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h2, "entraDeviceID2", "entraUserPrincipalName2"), http.StatusOK, &distributedResp)
h2s, err := s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID2", h2s.DeviceID)
require.Equal(t, "entraUserPrincipalName2", h2s.UserPrincipalName)
require.Nil(t, h2s.Managed)
require.Nil(t, h2s.Compliant)
setDone = make(chan struct{})
// Now the host is failing a the compliance policy.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
h2,
map[uint]*bool{
cp1.ID: ptr.Bool(false),
p2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
select {
case <-setDone:
time.Sleep(1 * time.Second)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for compliance to be set")
}
h2s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID2", h2s.DeviceID)
require.Equal(t, "entraUserPrincipalName2", h2s.UserPrincipalName)
require.NotNil(t, h2s.Managed)
require.True(t, *h2s.Managed)
require.NotNil(t, h2s.Compliant)
require.False(t, *h2s.Compliant) // host is non-compliant
// Delete compliance policy, now there should be no compliance policies so host should be compliant.
_, err = s.ds.DeleteTeamPolicies(ctx, fleet.PolicyNoTeamID, []uint{cp1.ID})
require.NoError(t, err)
setDone = make(chan struct{})
// Now the host is failing a compliance policy.
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
h2,
map[uint]*bool{
p2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
select {
case <-setDone:
time.Sleep(1 * time.Second)
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for compliance to be set")
}
h2s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID2", h2s.DeviceID)
require.Equal(t, "entraUserPrincipalName2", h2s.UserPrincipalName)
require.NotNil(t, h2s.Managed)
require.True(t, *h2s.Managed)
require.NotNil(t, h2s.Compliant)
require.True(t, *h2s.Compliant) // now the host is compliant
// A change of device ID and user principal name should update and clear the managed and compliant values.
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h2, "entraDeviceID3", "entraUserPrincipalName3"), http.StatusOK, &distributedResp)
h2s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID)
require.NoError(t, err)
require.Equal(t, "entraDeviceID3", h2s.DeviceID)
require.Equal(t, "entraUserPrincipalName3", h2s.UserPrincipalName)
require.Nil(t, h2s.Managed)
require.Nil(t, h2s.Compliant)
}
func (s *integrationEnterpriseTestSuite) TestOrbitSetupExperienceStatusChecksAuthBeforeMDM() {
t := s.T()
// macOS host should fail with MDM not enabled and configured.
h1 := createOrbitEnrolledHost(t, "darwin", "h1", s.ds)
var orbitRes getOrbitSetupExperienceStatusResponse
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *h1.OrbitNodeKey}, http.StatusBadRequest, &orbitRes,
)
// Linux host should not fail with MDM not enabled and configured.
h2 := createOrbitEnrolledHost(t, "ubuntu", "h2", s.ds)
orbitRes = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *h2.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.Empty(t, orbitRes.Results)
}
func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
// Get "Setup experience" items.
var respGetSetupExperience getSetupExperienceSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{}, http.StatusOK, &respGetSetupExperience,
"platform", "linux",
"team_id", fmt.Sprint(team.ID),
)
require.Empty(t, respGetSetupExperience.SoftwareTitles)
// Add a deb package to the team.
debPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for vim.deb",
Filename: "vim.deb",
Title: "vim",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, debPackage, http.StatusOK, "")
debVimTitleID := getSoftwareTitleID(t, s.ds, "vim", "deb_packages")
// Add an rpm package to the team.
rpmPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for ruby.rpm",
Filename: "ruby.rpm",
Title: "ruby",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, rpmPackage, http.StatusOK, "")
rubyVimTitleID := getSoftwareTitleID(t, s.ds, "ruby", "rpm_packages")
// Add a tar.gz package to the team.
tarGzPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for test.tar.gz",
UninstallScript: "uninstall script for test.tar.gz",
Filename: "test.tar.gz",
Title: "test",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, tarGzPackage, http.StatusOK, "")
// (tar.gz include the extension on their title.)
tarGzPackageTitleID := getSoftwareTitleID(t, s.ds, "test.tar.gz", "tgz_packages")
// Configure the deb, rpm, and tar.gz packages to run as part of the setup experience for Linux hosts.
var swInstallResp putSetupExperienceSoftwareResponse
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "linux",
TeamID: team.ID,
TitleIDs: []uint{debVimTitleID, rubyVimTitleID, tarGzPackageTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
s.lastActivityOfTypeMatches(fleet.ActivityEditedSetupExperienceSoftware{}.ActivityName(),
fmt.Sprintf(`{"platform": "linux", "team_id": %d, "team_name": "%s"}`, team.ID, team.Name), 0)
// Get "Setup experience" items.
respGetSetupExperience = getSetupExperienceSoftwareResponse{}
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software",
getSetupExperienceSoftwareRequest{},
http.StatusOK,
&respGetSetupExperience,
"platform", "linux",
"team_id", fmt.Sprint(team.ID),
"order_key", "id",
"order_direction", "asc",
)
require.Len(t, respGetSetupExperience.SoftwareTitles, 3)
require.Equal(t, "vim", respGetSetupExperience.SoftwareTitles[0].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.Equal(t, "ruby", respGetSetupExperience.SoftwareTitles[1].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[1].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[1].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[1].SoftwarePackage.InstallDuringSetup)
require.Equal(t, "test.tar.gz", respGetSetupExperience.SoftwareTitles[2].Name) // (tar.gz include the extension on their title.)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[2].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[2].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[2].SoftwarePackage.InstallDuringSetup)
createHost := func(hostPlatform, hostPlatformLike string) *fleet.Host {
name := t.Name() + "-" + hostPlatform
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Minute),
OsqueryHostID: ptr.String(name),
NodeKey: ptr.String(name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.local", name),
HardwareSerial: uuid.New().String(),
Platform: hostPlatform,
PlatformLike: hostPlatformLike,
TeamID: &team.ID,
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
err = s.ds.SetOrUpdateDeviceAuthToken(ctx, host.ID, "fleet-desktop-token-"+hostPlatform)
require.NoError(t, err)
return host
}
user1 := test.NewUser(t, s.ds, "Alice", "alice@example.com", true)
teamPolicy, err := s.ds.NewTeamPolicy(ctx, team.ID, &user1.ID, fleet.PolicyPayload{
Name: "foobar",
Query: "SELECT 1;",
})
require.NoError(t, err)
ubuntuHost := createHost("ubuntu", "debian")
fedoraHost := createHost("rhel", "rhel")
t.Run("ubuntu-success", func(t *testing.T) {
// Get status of the "Setup experience" for the Ubuntu host (nothing yet).
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.Empty(t, getDeviceStatusResponse.Results.Software)
// Trigger "Setup experience" for the Ubuntu host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *ubuntuHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should not return policies because the host
// is running the setup experience).
s.lq.On("QueriesForHost", ubuntuHost.ID).Return(map[string]string{fmt.Sprintf("%d", ubuntuHost.ID): "SELECT 1 FROM osquery;"}, nil)
req := getDistributedQueriesRequest{NodeKey: *ubuntuHost.NodeKey}
var dqResp getDistributedQueriesResponse
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.NotContains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", teamPolicy.ID))
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["vim"])
require.NotEmpty(t, executionIDs["test.tar.gz"])
// Record a result for vim.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
executionIDs["vim"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[1].Status)
// Record a result for test.tar.gz.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
executionIDs["test.tar.gz"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should now return policies because the host is done with the setup experience).
req = getDistributedQueriesRequest{NodeKey: *ubuntuHost.NodeKey}
dqResp = getDistributedQueriesResponse{}
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", teamPolicy.ID))
})
t.Run("fedora-failure", func(t *testing.T) {
// Get status of the "Setup experience" for the Fedora host (nothing yet).
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-rhel/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.Empty(t, getDeviceStatusResponse.Results.Software)
// Trigger "Setup experience" for the Fedora host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *fedoraHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Fedora host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-rhel/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "ruby", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should not return policies because the host
// is running the setup experience).
s.lq.On("QueriesForHost", fedoraHost.ID).Return(map[string]string{fmt.Sprintf("%d", fedoraHost.ID): "SELECT 1 FROM osquery;"}, nil)
req := getDistributedQueriesRequest{NodeKey: *fedoraHost.NodeKey}
var dqResp getDistributedQueriesResponse
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.NotContains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", teamPolicy.ID))
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
fedoraHostUUID, err := fleet.HostUUIDForSetupExperience(fedoraHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, fedoraHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["ruby"])
require.NotEmpty(t, executionIDs["test.tar.gz"])
// Record a result for vim.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*fedoraHost.OrbitNodeKey,
executionIDs["ruby"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Fedora host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-rhel/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "ruby", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status)
// Record a result for test.tar.gz.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 1,
"install_script_output": "failed"
}`,
*fedoraHost.OrbitNodeKey,
executionIDs["test.tar.gz"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Fedora host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-rhel/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "ruby", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should now return policies because the host is done with the setup experience).
req = getDistributedQueriesRequest{NodeKey: *fedoraHost.NodeKey}
dqResp = getDistributedQueriesResponse{}
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", teamPolicy.ID))
})
t.Run("ubuntu-canceled", func(t *testing.T) {
// Trigger "Setup experience" for the Ubuntu host (should clear any items from a previous setup experience).
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *ubuntuHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Ubuntu host.
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[1].Status)
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["vim"])
require.NotEmpty(t, executionIDs["test.tar.gz"])
// Cancel the software install for vim.
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", ubuntuHost.ID, executionIDs["vim"]),
nil, http.StatusNoContent)
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status)
// Record a result for test.tar.gz.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
executionIDs["test.tar.gz"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status)
})
t.Run("ubuntu-software-edited-during-se", func(t *testing.T) {
// Trigger "Setup experience" for the Ubuntu host (should clear any items from a previous setup experience).
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *ubuntuHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Ubuntu host.
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[1].Status)
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["vim"])
require.NotEmpty(t, executionIDs["test.tar.gz"])
// Modify the vim installer, which should cause the setup experience item to fail.
// update should succeed
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
SelfService: ptr.Bool(true),
InstallScript: ptr.String("some updated install script"),
PreInstallQuery: ptr.String("some new pre install query"),
PostInstallScript: ptr.String("some new post install script"),
Filename: "vim.deb",
TitleID: debVimTitleID,
TeamID: &team.ID,
}, http.StatusOK, "")
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status)
s.lastActivityOfTypeMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
"host_id": %d,
"host_display_name": %q,
"software_title": %q,
"software_package": %q,
"install_uuid": %q,
"status": "failed",
"self_service": false,
"policy_name": null,
"policy_id": null
}`, ubuntuHost.ID, ubuntuHost.DisplayName(), "vim", "vim.deb", executionIDs["vim"]), 0)
// Record a result for test.tar.gz.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
executionIDs["test.tar.gz"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Ubuntu host.
// Editing the software causes the queued setup experience installation to be marked as failure.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status)
})
// Transfer the Ubuntu host to "No team".
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{ubuntuHost.ID}}, http.StatusOK)
// Add a deb package to "No team".
debEmacsPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for emacs.deb",
Filename: "emacs.deb",
Title: "emacs",
TeamID: nil,
}
s.uploadSoftwareInstaller(t, debEmacsPackage, http.StatusOK, "")
debEmacsTitleID := getSoftwareTitleID(t, s.ds, "emacs", "deb_packages")
// Configure the deb package to run as part of the setup experience for Linux hosts in "No team".
swInstallResp = putSetupExperienceSoftwareResponse{}
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "linux",
TeamID: 0,
TitleIDs: []uint{debEmacsTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
s.lastActivityOfTypeMatches(fleet.ActivityEditedSetupExperienceSoftware{}.ActivityName(),
`{"platform": "linux", "team_id": 0, "team_name": ""}`, 0)
// Get "Setup experience" items for "No team".
respGetSetupExperience = getSetupExperienceSoftwareResponse{}
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software",
getSetupExperienceSoftwareRequest{},
http.StatusOK,
&respGetSetupExperience,
"platform", "linux",
"team_id", "0",
"order_key", "id",
"order_direction", "asc",
)
require.Len(t, respGetSetupExperience.SoftwareTitles, 1)
require.Equal(t, "emacs", respGetSetupExperience.SoftwareTitles[0].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
t.Run("ubuntu-no-team", func(t *testing.T) {
// Trigger "Setup experience" for the Ubuntu host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *ubuntuHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Ubuntu host.
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 1)
require.Equal(t, "emacs", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID)
require.NoError(t, err)
require.Len(t, results, 1)
require.NotNil(t, results[0].HostSoftwareInstallsExecutionID)
emacsExecutionID := *results[0].HostSoftwareInstallsExecutionID
require.NotEmpty(t, emacsExecutionID)
// Record a result for vim.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
emacsExecutionID,
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Ubuntu host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 1)
require.Len(t, getDeviceStatusResponse.Results.Software, 1)
require.Equal(t, "emacs", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
})
}
func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftwareWithoutDesktop() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
// Add a deb package to the team.
debPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for vim.deb",
Filename: "vim.deb",
Title: "vim",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, debPackage, http.StatusOK, "")
debVimTitleID := getSoftwareTitleID(t, s.ds, "vim", "deb_packages")
// Add a tar.gz package to the team.
tarGzPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for test.tar.gz",
UninstallScript: "uninstall script for test.tar.gz",
Filename: "test.tar.gz",
Title: "test",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, tarGzPackage, http.StatusOK, "")
// (tar.gz include the extension on their title.)
tarGzPackageTitleID := getSoftwareTitleID(t, s.ds, "test.tar.gz", "tgz_packages")
// Configure the deb and tar.gz packages to run as part of the setup experience for Linux hosts.
var swInstallResp putSetupExperienceSoftwareResponse
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "linux",
TeamID: team.ID,
TitleIDs: []uint{debVimTitleID, tarGzPackageTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
createHost := func(hostPlatform, hostPlatformLike string) *fleet.Host {
name := t.Name() + "-" + hostPlatform
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Minute),
OsqueryHostID: ptr.String(name),
NodeKey: ptr.String(name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.local", name),
HardwareSerial: uuid.New().String(),
Platform: hostPlatform,
PlatformLike: hostPlatformLike,
TeamID: &team.ID,
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
err = s.ds.SetOrUpdateDeviceAuthToken(ctx, host.ID, "fleet-desktop-token-"+hostPlatform)
require.NoError(t, err)
return host
}
ubuntuHost := createHost("ubuntu", "debian")
// Get status of the "Setup experience" for the Ubuntu host (nothing yet).
var orbitRes getOrbitSetupExperienceStatusResponse
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *ubuntuHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.Empty(t, orbitRes.Results.Software)
// Trigger "Setup experience" for the Ubuntu host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *ubuntuHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" now that it has been triggered.
orbitRes = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *ubuntuHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "pending", orbitRes.Results.Software[0].Status)
require.Equal(t, "vim", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "pending", orbitRes.Results.Software[1].Status)
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["vim"])
require.NotEmpty(t, executionIDs["test.tar.gz"])
// Record a result for vim.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
executionIDs["vim"],
),
), http.StatusNoContent)
// Again get status of the "Setup experience" for the Ubuntu host.
orbitRes = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *ubuntuHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "running", orbitRes.Results.Software[0].Status)
require.Equal(t, "vim", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[1].Status)
// Record a result for test.tar.gz.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*ubuntuHost.OrbitNodeKey,
executionIDs["test.tar.gz"],
),
), http.StatusNoContent)
// One last time get status of the "Setup experience" for the Ubuntu host.
orbitRes = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *ubuntuHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "test.tar.gz", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[0].Status)
require.Equal(t, "vim", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[1].Status)
}
func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
// Get "Setup experience" items.
var respGetSetupExperience getSetupExperienceSoftwareResponse
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software", getSetupExperienceSoftwareRequest{}, http.StatusOK, &respGetSetupExperience,
"platform", "windows",
"team_id", fmt.Sprint(team.ID),
)
require.Empty(t, respGetSetupExperience.SoftwareTitles)
// Add a msi package to the team.
msiPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for fleet-osquery.msi",
Filename: "fleet-osquery.msi",
Title: "Fleet osquery",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, msiPackage, http.StatusOK, "")
msiPackageTitleID := getSoftwareTitleID(t, s.ds, "Fleet osquery", "programs")
// Add a exe installer to the team.
exePackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for hello-world-installer.exe",
UninstallScript: "uninstall script for hello-world-installer.exe", // required for .exe
Filename: "hello-world-installer.exe",
Title: "Hello world",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, exePackage, http.StatusOK, "")
exePackageTitleID := getSoftwareTitleID(t, s.ds, "Hello world", "programs")
// Configure the msi and exe packages to run as part of the setup experience for Windows hosts.
var swInstallResp putSetupExperienceSoftwareResponse
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "windows",
TeamID: team.ID,
TitleIDs: []uint{msiPackageTitleID, exePackageTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
s.lastActivityOfTypeMatches(fleet.ActivityEditedSetupExperienceSoftware{}.ActivityName(),
fmt.Sprintf(`{"platform": "windows", "team_id": %d, "team_name": "%s"}`, team.ID, team.Name), 0)
// Add a deb package to the Linux setup experience to test
// that only msi and exe are queued on Windows hosts.
debPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for vim.deb",
Filename: "vim.deb",
Title: "vim",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, debPackage, http.StatusOK, "")
debVimTitleID := getSoftwareTitleID(t, s.ds, "vim", "deb_packages")
swInstallResp = putSetupExperienceSoftwareResponse{}
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "linux",
TeamID: team.ID,
TitleIDs: []uint{debVimTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
// Get "Setup experience" items.
respGetSetupExperience = getSetupExperienceSoftwareResponse{}
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software",
getSetupExperienceSoftwareRequest{},
http.StatusOK,
&respGetSetupExperience,
"platform", "windows",
"team_id", fmt.Sprint(team.ID),
"order_key", "id",
"order_direction", "asc",
)
require.Len(t, respGetSetupExperience.SoftwareTitles, 2)
require.Equal(t, "Fleet osquery", respGetSetupExperience.SoftwareTitles[0].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.Equal(t, "Hello world", respGetSetupExperience.SoftwareTitles[1].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[1].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[1].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[1].SoftwarePackage.InstallDuringSetup)
i := 0
createHost := func(team *fleet.Team, hostPlatform, hostPlatformLike string) *fleet.Host {
name := t.Name() + "-" + hostPlatform + fmt.Sprint(i)
var teamID *uint
if team != nil {
teamID = &team.ID
}
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Minute),
OsqueryHostID: ptr.String(name),
NodeKey: ptr.String(name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.local", name),
HardwareSerial: uuid.New().String(),
Platform: hostPlatform,
PlatformLike: hostPlatformLike,
TeamID: teamID,
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
err = s.ds.SetOrUpdateDeviceAuthToken(ctx, host.ID, "fleet-desktop-token-"+fmt.Sprint(i))
require.NoError(t, err)
i++
return host
}
user1 := test.NewUser(t, s.ds, "Alice", "alice@example.com", true)
teamPolicy, err := s.ds.NewTeamPolicy(ctx, team.ID, &user1.ID, fleet.PolicyPayload{
Name: "foobar",
Query: "SELECT 1;",
})
require.NoError(t, err)
windowsHost1 := createHost(team, "windows", "windows")
t.Run("windows-success", func(t *testing.T) {
// Get status of the "Setup experience" for the Windows host (nothing yet).
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-0/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.Empty(t, getDeviceStatusResponse.Results.Software)
// Trigger "Setup experience" for the Windows host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *windowsHost1.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Windows host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-0/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should not return policies because the host
// is running the setup experience).
s.lq.On("QueriesForHost", windowsHost1.ID).Return(map[string]string{fmt.Sprintf("%d", windowsHost1.ID): "SELECT 1 FROM osquery;"}, nil)
req := getDistributedQueriesRequest{NodeKey: *windowsHost1.NodeKey}
var dqResp getDistributedQueriesResponse
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.NotContains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", teamPolicy.ID))
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
windowsHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost1)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["Fleet osquery"])
require.NotEmpty(t, executionIDs["Hello world"])
// Record a result for Fleet osquery.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*windowsHost1.OrbitNodeKey,
executionIDs["Fleet osquery"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Windows host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-0/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status)
// Record a result for Hello world.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*windowsHost1.OrbitNodeKey,
executionIDs["Hello world"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Windows host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-0/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should now return policies because the host is done with the setup experience).
req = getDistributedQueriesRequest{NodeKey: *windowsHost1.NodeKey}
dqResp = getDistributedQueriesResponse{}
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", teamPolicy.ID))
// Get status of the "Setup experience" via orbit (orbit will use it to mark the setup experience as done).
var orbitRes getOrbitSetupExperienceStatusResponse
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost1.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[0].Status)
require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[1].Status)
})
// Add a msi package to "No team".
msiPackageNoTeam := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for fleet-osquery.msi",
Filename: "fleet-osquery.msi",
Title: "Fleet osquery",
TeamID: nil,
}
s.uploadSoftwareInstaller(t, msiPackageNoTeam, http.StatusOK, "")
msiPackageTitleID = getSoftwareTitleID(t, s.ds, "Fleet osquery", "programs")
// Add a exe installer to the team.
exePackageNoTeam := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for hello-world-installer.exe",
UninstallScript: "uninstall script for hello-world-installer.exe", // required for .exe
Filename: "hello-world-installer.exe",
Title: "Hello world",
TeamID: nil,
}
s.uploadSoftwareInstaller(t, exePackageNoTeam, http.StatusOK, "")
exePackageTitleID = getSoftwareTitleID(t, s.ds, "Hello world", "programs")
// Configure the msi and exe packages to run as part of the setup experience for Windows hosts in "No team".
swInstallResp = putSetupExperienceSoftwareResponse{}
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "windows",
TeamID: uint(0),
TitleIDs: []uint{msiPackageTitleID, exePackageTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
s.lastActivityOfTypeMatches(fleet.ActivityEditedSetupExperienceSoftware{}.ActivityName(),
`{"platform": "windows", "team_id": 0, "team_name": ""}`, 0)
// Add a deb package to the Linux setup experience to test
// that only msi and exe are queued on Windows hosts.
debPackageNoTeam := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for vim.deb",
Filename: "vim.deb",
Title: "vim",
TeamID: nil,
}
s.uploadSoftwareInstaller(t, debPackageNoTeam, http.StatusOK, "")
debVimTitleID = getSoftwareTitleID(t, s.ds, "vim", "deb_packages")
swInstallResp = putSetupExperienceSoftwareResponse{}
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "linux",
TeamID: uint(0),
TitleIDs: []uint{debVimTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
// Get "Setup experience" items for "No team".
respGetSetupExperience = getSetupExperienceSoftwareResponse{}
s.DoJSON("GET", "/api/latest/fleet/setup_experience/software",
getSetupExperienceSoftwareRequest{},
http.StatusOK,
&respGetSetupExperience,
"platform", "windows",
"team_id", "0",
"order_key", "id",
"order_direction", "asc",
)
require.Len(t, respGetSetupExperience.SoftwareTitles, 2)
require.Equal(t, "Fleet osquery", respGetSetupExperience.SoftwareTitles[0].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[0].SoftwarePackage.InstallDuringSetup)
require.Equal(t, "Hello world", respGetSetupExperience.SoftwareTitles[1].Name)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[1].SoftwarePackage)
require.NotNil(t, respGetSetupExperience.SoftwareTitles[1].SoftwarePackage.InstallDuringSetup)
require.True(t, *respGetSetupExperience.SoftwareTitles[1].SoftwarePackage.InstallDuringSetup)
windowsHost2 := createHost(nil, "windows", "windows")
globalPolicy, err := s.ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{
Name: "foobar",
Query: "SELECT 1;",
})
require.NoError(t, err)
t.Run("windows-failure-no-team", func(t *testing.T) {
// Get status of the "Setup experience" for the Windows host (nothing yet).
var getDeviceStatusResponse getDeviceSetupExperienceStatusResponse
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-1/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.Empty(t, getDeviceStatusResponse.Results.Software)
// Trigger "Setup experience" for the Windows host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *windowsHost2.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" for the Windows host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-1/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "pending", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should not return policies because the host
// is running the setup experience).
s.lq.On("QueriesForHost", windowsHost2.ID).Return(map[string]string{fmt.Sprintf("%d", windowsHost2.ID): "SELECT 1 FROM osquery;"}, nil)
req := getDistributedQueriesRequest{NodeKey: *windowsHost2.NodeKey}
var dqResp getDistributedQueriesResponse
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.NotContains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", globalPolicy.ID))
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
windowsHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost2)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["Fleet osquery"])
require.NotEmpty(t, executionIDs["Hello world"])
// Record a failing result for Fleet osquery.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 1,
"install_script_output": "ok"
}`,
*windowsHost2.OrbitNodeKey,
executionIDs["Fleet osquery"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Windows host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-1/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status)
// Record a result for Hello world.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*windowsHost2.OrbitNodeKey,
executionIDs["Hello world"],
),
), http.StatusNoContent)
// Get status of the "Setup experience" for the Windows host.
getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-1/setup_experience/status",
getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse,
)
require.NoError(t, getDeviceStatusResponse.Err)
require.NotNil(t, getDeviceStatusResponse.Results)
require.Len(t, getDeviceStatusResponse.Results.Software, 2)
sort.Slice(getDeviceStatusResponse.Results.Software, func(i, j int) bool {
return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", getDeviceStatusResponse.Results.Software[0].Name)
require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[0].Status)
require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name)
require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[1].Status)
// Get distributed queries for the host (should now return policies because the host is done with the setup experience).
req = getDistributedQueriesRequest{NodeKey: *windowsHost2.NodeKey}
dqResp = getDistributedQueriesResponse{}
s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp)
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_policy_query_%d", globalPolicy.ID))
// Get status of the "Setup experience" via orbit (orbit will use it to mark the setup experience as done).
var orbitRes getOrbitSetupExperienceStatusResponse
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost2.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "failure", orbitRes.Results.Software[0].Status)
require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[1].Status)
})
}
func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareWithoutDesktop() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
// Add a msi package to the team.
msiPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for fleet-osquery.msi",
Filename: "fleet-osquery.msi",
Title: "Fleet osquery",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, msiPackage, http.StatusOK, "")
msiPackageTitleID := getSoftwareTitleID(t, s.ds, "Fleet osquery", "programs")
// Add a exe installer to the team.
exePackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for hello-world-installer.exe",
UninstallScript: "uninstall script for hello-world-installer.exe", // required for .exe
Filename: "hello-world-installer.exe",
Title: "Hello world",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, exePackage, http.StatusOK, "")
exePackageTitleID := getSoftwareTitleID(t, s.ds, "Hello world", "programs")
// Add a deb package to the team.
debPackage := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script for vim.deb",
Filename: "vim.deb",
Title: "vim",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, debPackage, http.StatusOK, "")
debVimTitleID := getSoftwareTitleID(t, s.ds, "vim", "deb_packages")
// Configure the deb package to run as part of the setup experience for Linux hosts.
var swInstallResp putSetupExperienceSoftwareResponse
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "linux",
TeamID: team.ID,
TitleIDs: []uint{debVimTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
// Configure the msi and exe packages to run as part of the setup experience for Windows hosts.
swInstallResp = putSetupExperienceSoftwareResponse{}
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: "windows",
TeamID: team.ID,
TitleIDs: []uint{msiPackageTitleID, exePackageTitleID},
}, http.StatusOK, &swInstallResp)
require.NoError(t, swInstallResp.Err)
createHost := func(hostPlatform, hostPlatformLike string) *fleet.Host {
name := t.Name() + "-" + hostPlatform
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Minute),
OsqueryHostID: ptr.String(name),
NodeKey: ptr.String(name),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%s.local", name),
HardwareSerial: uuid.New().String(),
Platform: hostPlatform,
PlatformLike: hostPlatformLike,
TeamID: &team.ID,
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
err = s.ds.SetOrUpdateDeviceAuthToken(ctx, host.ID, "fleet-desktop-token-"+hostPlatform)
require.NoError(t, err)
return host
}
windowsHost := createHost("windows", "windows")
// Trigger "Setup experience" for the Windows host.
var orbitInitResponse orbitSetupExperienceInitResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/setup_experience/init", orbitSetupExperienceInitRequest{
OrbitNodeKey: *windowsHost.OrbitNodeKey,
}, http.StatusOK, &orbitInitResponse,
)
require.NoError(t, orbitInitResponse.Err)
require.True(t, orbitInitResponse.Result.Enabled)
// Get status of the "Setup experience" now that it has been triggered.
var orbitRes getOrbitSetupExperienceStatusResponse
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "pending", orbitRes.Results.Software[0].Status)
require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "pending", orbitRes.Results.Software[1].Status)
// The setup_experience/status endpoint doesn't return the various IDs for executions,
// so pull it out manually
ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost)
require.NoError(t, err)
results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID)
require.NoError(t, err)
require.Len(t, results, 2)
executionIDs := make(map[string]string) // installer name -> install execution ID
for _, result := range results {
if result.HostSoftwareInstallsExecutionID != nil {
executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID
}
}
require.NotEmpty(t, executionIDs["Fleet osquery"])
require.NotEmpty(t, executionIDs["Hello world"])
// Record a result for Fleet osquery.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*windowsHost.OrbitNodeKey,
executionIDs["Fleet osquery"],
),
), http.StatusNoContent)
// Again get status of the "Setup experience" for the Windos host.
orbitRes = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[0].Status)
require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "running", orbitRes.Results.Software[1].Status)
// Record a result for Hello world.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(
fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
}`,
*windowsHost.OrbitNodeKey,
executionIDs["Hello world"],
),
), http.StatusNoContent)
// One last time get status of the "Setup experience" for the Ubuntu host.
orbitRes = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status",
getOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost.OrbitNodeKey}, http.StatusOK, &orbitRes,
)
require.NotNil(t, orbitRes.Results)
require.NotNil(t, orbitRes.Results)
require.Len(t, orbitRes.Results.Software, 2)
sort.Slice(orbitRes.Results.Software, func(i, j int) bool {
return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name
})
require.Equal(t, "Fleet osquery", orbitRes.Results.Software[0].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[0].Status)
require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name)
require.EqualValues(t, "success", orbitRes.Results.Software[1].Status)
}