2021-09-15 19:27:53 +00:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2022-09-19 17:53:44 +00:00
|
|
|
"context"
|
2021-09-15 19:27:53 +00:00
|
|
|
"encoding/json"
|
2024-12-27 18:10:28 +00:00
|
|
|
"errors"
|
2021-09-15 19:27:53 +00:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-02-28 11:52:21 +00:00
|
|
|
"log/slog"
|
2024-10-14 20:41:06 +00:00
|
|
|
"mime/multipart"
|
2021-09-15 19:27:53 +00:00
|
|
|
"net/http"
|
2022-08-15 17:42:33 +00:00
|
|
|
"net/http/cookiejar"
|
2021-09-15 19:27:53 +00:00
|
|
|
"net/http/httptest"
|
2022-08-15 17:42:33 +00:00
|
|
|
"net/url"
|
|
|
|
|
"os"
|
2024-10-14 20:41:06 +00:00
|
|
|
"path/filepath"
|
2022-08-15 17:42:33 +00:00
|
|
|
"regexp"
|
2023-04-12 19:11:04 +00:00
|
|
|
"sync"
|
2022-09-21 19:16:31 +00:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
2021-09-15 19:27:53 +00:00
|
|
|
|
2021-11-24 20:56:54 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
2023-02-28 17:55:04 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
2021-09-15 19:27:53 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
2024-10-31 19:24:42 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
|
2021-09-15 19:27:53 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
2023-01-09 11:56:10 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/live_query/live_query_mock"
|
2026-01-19 14:07:14 +00:00
|
|
|
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
2022-02-15 20:22:19 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/pubsub"
|
2025-04-10 19:08:45 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/service/contract"
|
2021-09-15 19:27:53 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/test"
|
2025-02-26 16:47:05 +00:00
|
|
|
fleet_httptest "github.com/fleetdm/fleet/v4/server/test/httptest"
|
2021-09-15 19:27:53 +00:00
|
|
|
"github.com/ghodss/yaml"
|
2023-10-10 22:00:45 +00:00
|
|
|
"github.com/jmoiron/sqlx"
|
2021-09-15 19:27:53 +00:00
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
"github.com/stretchr/testify/suite"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-18 18:58:58 +00:00
|
|
|
// testSAMLIDPBaseURL is the SAML IDP base URL, read from FLEET_SAML_IDP_HTTP_PORT (defaults to http://localhost:9080).
|
2026-04-07 20:23:59 +00:00
|
|
|
var (
|
|
|
|
|
testSAMLIDPBaseURL = getTestSAMLIDPBaseURL()
|
|
|
|
|
testSAMLIDPMetadataURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/metadata.php"
|
|
|
|
|
testSAMLIDPSSOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SSOService.php"
|
|
|
|
|
testSAMLIDPSLOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SingleLogoutService.php"
|
|
|
|
|
)
|
2026-03-18 18:58:58 +00:00
|
|
|
|
|
|
|
|
func getTestSAMLIDPBaseURL() string {
|
|
|
|
|
if port := os.Getenv("FLEET_SAML_IDP_HTTP_PORT"); port != "" {
|
|
|
|
|
return "http://localhost:" + port
|
|
|
|
|
}
|
|
|
|
|
return "http://localhost:9080"
|
|
|
|
|
}
|
|
|
|
|
|
2021-09-15 19:27:53 +00:00
|
|
|
type withDS struct {
|
2026-01-19 14:07:14 +00:00
|
|
|
s *suite.Suite
|
|
|
|
|
ds *mysql.Datastore
|
|
|
|
|
dbConns *common_mysql.DBConnections
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withDS) SetupSuite(dbName string) {
|
2022-09-19 17:53:44 +00:00
|
|
|
t := ts.s.T()
|
2026-01-19 14:07:14 +00:00
|
|
|
ts.ds, ts.dbConns = mysql.CreateNamedMySQLDSWithConns(t, dbName)
|
2024-04-03 19:44:23 +00:00
|
|
|
// remove any migration-created labels
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
2024-04-15 20:10:10 +00:00
|
|
|
_, err := q.ExecContext(context.Background(), `DELETE FROM labels`)
|
2024-04-03 19:44:23 +00:00
|
|
|
return err
|
|
|
|
|
})
|
2024-04-08 19:34:55 +00:00
|
|
|
test.AddBuiltinLabels(t, ts.ds)
|
2022-09-19 17:53:44 +00:00
|
|
|
|
|
|
|
|
// setup the required fields on AppConfig
|
|
|
|
|
appConf, err := ts.ds.AppConfig(context.Background())
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
appConf.OrgInfo.OrgName = "FleetTest"
|
|
|
|
|
appConf.ServerSettings.ServerURL = "https://example.org"
|
|
|
|
|
err = ts.ds.SaveAppConfig(context.Background(), appConf)
|
|
|
|
|
require.NoError(t, err)
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withDS) TearDownSuite() {
|
|
|
|
|
ts.ds.Close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type withServer struct {
|
|
|
|
|
withDS
|
|
|
|
|
|
2022-03-08 16:27:38 +00:00
|
|
|
server *httptest.Server
|
|
|
|
|
users map[string]fleet.User
|
|
|
|
|
token string
|
|
|
|
|
cachedAdminToken string
|
2023-04-12 19:11:04 +00:00
|
|
|
|
|
|
|
|
cachedTokensMu sync.Mutex
|
|
|
|
|
cachedTokens map[string]string // email -> auth token
|
|
|
|
|
|
|
|
|
|
lq *live_query_mock.MockLiveQuery
|
2025-09-26 18:03:50 +00:00
|
|
|
|
|
|
|
|
redisPool fleet.RedisPool
|
2026-02-25 20:11:03 +00:00
|
|
|
|
|
|
|
|
fleetSvc fleet.Service
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) SetupSuite(dbName string) {
|
|
|
|
|
ts.withDS.SetupSuite(dbName)
|
|
|
|
|
|
2022-02-15 20:22:19 +00:00
|
|
|
rs := pubsub.NewInmemQueryResults()
|
2023-02-28 17:55:04 +00:00
|
|
|
cfg := config.TestConfig()
|
|
|
|
|
cfg.Osquery.EnrollCooldown = 0
|
2025-09-26 18:03:50 +00:00
|
|
|
redisPool := redistest.SetupRedis(ts.s.T(), "integration_core", false, false, false)
|
2024-03-13 14:27:29 +00:00
|
|
|
opts := &TestServerOpts{
|
2023-02-28 17:55:04 +00:00
|
|
|
Rs: rs,
|
|
|
|
|
Lq: ts.lq,
|
|
|
|
|
FleetConfig: &cfg,
|
2025-09-26 18:03:50 +00:00
|
|
|
Pool: redisPool,
|
2026-01-19 14:07:14 +00:00
|
|
|
DBConns: ts.dbConns,
|
2024-03-13 14:27:29 +00:00
|
|
|
}
|
|
|
|
|
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
|
2026-02-28 11:52:21 +00:00
|
|
|
opts.Logger = slog.New(slog.DiscardHandler)
|
2024-03-13 14:27:29 +00:00
|
|
|
}
|
|
|
|
|
users, server := RunServerForTestsWithDS(ts.s.T(), ts.ds, opts)
|
2021-09-15 19:27:53 +00:00
|
|
|
ts.server = server
|
|
|
|
|
ts.users = users
|
|
|
|
|
ts.token = ts.getTestAdminToken()
|
2022-03-08 16:27:38 +00:00
|
|
|
ts.cachedAdminToken = ts.token
|
2025-09-26 18:03:50 +00:00
|
|
|
ts.redisPool = redisPool
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) TearDownSuite() {
|
|
|
|
|
ts.withDS.TearDownSuite()
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-21 19:16:31 +00:00
|
|
|
func (ts *withServer) commonTearDownTest(t *testing.T) {
|
2024-04-04 17:58:31 +00:00
|
|
|
// By setting DISABLE_TABLES_CLEANUP a developer can troubleshoot tests
|
|
|
|
|
// by inspecting mysql tables.
|
|
|
|
|
if os.Getenv("DISABLE_CLEANUP_TABLES") != "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2022-09-21 19:16:31 +00:00
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
u := ts.users["admin1@example.com"]
|
|
|
|
|
filter := fleet.TeamFilter{User: &u}
|
|
|
|
|
hosts, err := ts.ds.ListHosts(ctx, filter, fleet.HostListOptions{})
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
for _, host := range hosts {
|
2023-05-17 18:49:09 +00:00
|
|
|
_, err := ts.ds.UpdateHostSoftware(context.Background(), host.ID, nil)
|
|
|
|
|
require.NoError(t, err)
|
2022-09-21 19:16:31 +00:00
|
|
|
require.NoError(t, ts.ds.DeleteHost(ctx, host.ID))
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-11 19:21:10 +00:00
|
|
|
teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{})
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
for _, tm := range teams {
|
|
|
|
|
err := ts.ds.DeleteTeam(ctx, tm.ID)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
|
|
|
|
|
return err
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
|
2025-10-28 12:33:58 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = q.ExecContext(ctx, "DELETE FROM in_house_apps;")
|
2025-12-15 17:11:36 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = q.ExecContext(ctx, "DELETE FROM vpp_apps;")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
2024-12-11 19:21:10 +00:00
|
|
|
})
|
|
|
|
|
|
2025-12-30 03:28:45 +00:00
|
|
|
lbls, err := ts.ds.ListLabels(ctx, filter, fleet.ListOptions{}, false)
|
2022-09-21 19:16:31 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
for _, lbl := range lbls {
|
|
|
|
|
if lbl.LabelType != fleet.LabelTypeBuiltIn {
|
2025-12-30 03:28:45 +00:00
|
|
|
err := ts.ds.DeleteLabel(ctx, lbl.Name, filter)
|
2022-09-21 19:16:31 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Show Manage Automations disabled button with tooltip on Queries page (#39302)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #39303 (child of #25080).
- Added `inherited_query_count` to `ListQueriesResponse` (thought of
adding a brand new endpoint just for counting, but felt like extending
the current one was good enough). In the parent task, [it was
suggested](https://github.com/fleetdm/fleet/issues/25080#issuecomment-3326071574)
to `"Depend on team list entity endpoint's count field / team entity
count endpoint for whether or not to disable the manage automations
button"`, which Rachael approved, so I went for this approach.
- The `ManageQueryAutomationsModal` now fetches its own data with
`merge_inherited = false` (meaning it only fetches non-inherited queries
only). Previously, queries were passed down as props to it, which would
not show the queries available to automate if the first page of queries
were all inherited and the second page contained queries for that team
(the user would have to navigate to the second page for the button to be
enabled).
^ The fact that the modal fetches its own data is similar behavior to
what is currently done in `Policies`. For queries, I noticed that we
would need to add pagination within the `Manage Automations` modal, but
that can be a follow-up.
<img width="2480" height="1309" alt="Screenshot 2026-02-04 at 11 48
42 AM"
src="https://github.com/user-attachments/assets/ebac79a5-a793-4708-9313-d9a697dfd7de"
/>
# Checklist for submitter
- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
## Testing
- [x] QA'd all new/changed functionality manually
https://github.com/user-attachments/assets/119f03b9-dde1-4bb9-9fee-6204b1a58879
2026-02-09 18:16:28 +00:00
|
|
|
queries, _, _, _, err := ts.ds.ListQueries(ctx, fleet.ListQueryOptions{})
|
2023-07-25 00:17:20 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
queryIDs := make([]uint, 0, len(queries))
|
|
|
|
|
for _, query := range queries {
|
|
|
|
|
queryIDs = append(queryIDs, query.ID)
|
|
|
|
|
}
|
|
|
|
|
if len(queryIDs) > 0 {
|
|
|
|
|
count, err := ts.ds.DeleteQueries(ctx, queryIDs)
|
|
|
|
|
require.NoError(t, err)
|
2024-10-18 17:38:26 +00:00
|
|
|
require.EqualValues(t, len(queries), count)
|
2023-07-25 00:17:20 +00:00
|
|
|
}
|
|
|
|
|
|
2022-09-21 19:16:31 +00:00
|
|
|
users, err := ts.ds.ListUsers(ctx, fleet.UserListOptions{})
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
for _, u := range users {
|
|
|
|
|
if _, ok := ts.users[u.Email]; !ok {
|
|
|
|
|
err := ts.ds.DeleteUser(ctx, u.ID)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-04 01:03:40 +00:00
|
|
|
// Clean scripts in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`)
|
|
|
|
|
return err
|
|
|
|
|
})
|
|
|
|
|
|
2023-08-30 22:30:17 +00:00
|
|
|
globalPolicies, err := ts.ds.ListGlobalPolicies(ctx, fleet.ListOptions{})
|
2022-09-21 19:16:31 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
if len(globalPolicies) > 0 {
|
|
|
|
|
var globalPolicyIDs []uint
|
|
|
|
|
for _, gp := range globalPolicies {
|
|
|
|
|
globalPolicyIDs = append(globalPolicyIDs, gp.ID)
|
|
|
|
|
}
|
|
|
|
|
_, err = ts.ds.DeleteGlobalPolicies(ctx, globalPolicyIDs)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-01 18:14:49 +00:00
|
|
|
packs, err := ts.ds.ListPacks(ctx, fleet.PackListOptions{})
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
for _, pack := range packs {
|
|
|
|
|
err := ts.ds.DeletePack(ctx, pack.Name)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-14 12:37:07 +00:00
|
|
|
// Do the software/titles cleanup.
|
2022-09-21 19:16:31 +00:00
|
|
|
err = ts.ds.SyncHostsSoftware(ctx, time.Now())
|
|
|
|
|
require.NoError(t, err)
|
2025-10-14 22:36:45 +00:00
|
|
|
err = ts.ds.CleanupSoftwareTitles(ctx)
|
2024-05-14 12:37:07 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
err = ts.ds.SyncHostsSoftwareTitles(ctx, time.Now())
|
|
|
|
|
require.NoError(t, err)
|
2023-10-10 22:00:45 +00:00
|
|
|
|
|
|
|
|
// delete orphaned scripts
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, `DELETE FROM scripts`)
|
|
|
|
|
return err
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// delete orphaned host_script_results
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, `DELETE FROM host_script_results`)
|
|
|
|
|
return err
|
|
|
|
|
})
|
2024-09-06 18:36:41 +00:00
|
|
|
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(tx sqlx.ExtContext) error {
|
|
|
|
|
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;")
|
|
|
|
|
return err
|
|
|
|
|
})
|
2024-12-20 21:40:23 +00:00
|
|
|
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(tx sqlx.ExtContext) error {
|
|
|
|
|
_, err := tx.ExecContext(ctx, "DELETE FROM secret_variables")
|
|
|
|
|
return err
|
|
|
|
|
})
|
2025-05-07 23:16:08 +00:00
|
|
|
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, "DELETE FROM fleet_maintained_apps; ")
|
|
|
|
|
return err
|
|
|
|
|
})
|
|
|
|
|
// Most tests reference FMAs by ID, and the expect the records to be inserted starting with 1, so we need to reset the auto increment.
|
|
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, "ALTER TABLE fleet_maintained_apps AUTO_INCREMENT = 1;")
|
|
|
|
|
return err
|
|
|
|
|
})
|
2025-05-29 19:26:55 +00:00
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, "DELETE FROM invites; ")
|
|
|
|
|
return err
|
|
|
|
|
})
|
2026-01-26 22:58:31 +00:00
|
|
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
|
|
|
|
_, err := q.ExecContext(ctx, "DELETE FROM host_conditional_access")
|
|
|
|
|
return err
|
|
|
|
|
})
|
2022-09-21 19:16:31 +00:00
|
|
|
}
|
|
|
|
|
|
2021-10-07 11:25:35 +00:00
|
|
|
func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response {
|
2021-09-15 19:27:53 +00:00
|
|
|
t := ts.s.T()
|
|
|
|
|
|
|
|
|
|
j, err := json.Marshal(params)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
2021-10-07 11:25:35 +00:00
|
|
|
resp := ts.DoRaw(verb, path, j, expectedStatusCode, queryParams...)
|
2021-09-15 19:27:53 +00:00
|
|
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
})
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-07 11:25:35 +00:00
|
|
|
func (ts *withServer) DoRawWithHeaders(
|
|
|
|
|
verb string, path string, rawBytes []byte, expectedStatusCode int, headers map[string]string, queryParams ...string,
|
|
|
|
|
) *http.Response {
|
2025-07-07 18:13:46 +00:00
|
|
|
opts := []fleethttp.ClientOpt{}
|
|
|
|
|
if expectedStatusCode >= 300 && expectedStatusCode <= 399 {
|
|
|
|
|
opts = append(opts, fleethttp.WithFollowRedir(false))
|
|
|
|
|
}
|
|
|
|
|
client := fleethttp.NewClient(opts...)
|
|
|
|
|
return fleet_httptest.DoHTTPReq(ts.s.T(), client, decodeJSON, verb, rawBytes, ts.server.URL+path, headers, expectedStatusCode, queryParams...)
|
2025-02-26 16:47:05 +00:00
|
|
|
}
|
2021-09-15 19:27:53 +00:00
|
|
|
|
2025-02-26 16:47:05 +00:00
|
|
|
func decodeJSON(r io.Reader, v interface{}) error {
|
|
|
|
|
return json.NewDecoder(r).Decode(v)
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
2021-10-07 11:25:35 +00:00
|
|
|
func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
|
2021-09-15 19:27:53 +00:00
|
|
|
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{
|
|
|
|
|
"Authorization": fmt.Sprintf("Bearer %s", ts.token),
|
2021-10-07 11:25:35 +00:00
|
|
|
}, queryParams...)
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
2025-02-24 17:52:39 +00:00
|
|
|
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
|
|
|
|
|
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil, queryParams...)
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
2021-10-07 11:25:35 +00:00
|
|
|
func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {
|
|
|
|
|
resp := ts.Do(verb, path, params, expectedStatusCode, queryParams...)
|
2021-09-15 19:27:53 +00:00
|
|
|
err := json.NewDecoder(resp.Body).Decode(v)
|
|
|
|
|
require.NoError(ts.s.T(), err)
|
2025-02-14 22:19:34 +00:00
|
|
|
if e, ok := v.(fleet.Errorer); ok {
|
2025-02-03 17:23:26 +00:00
|
|
|
require.NoError(ts.s.T(), e.Error())
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-30 17:13:25 +00:00
|
|
|
func (ts *withServer) DoJSONWithoutAuth(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
rawBytes, err := json.Marshal(params)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
resp := ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{}, queryParams...)
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
})
|
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(v)
|
|
|
|
|
require.NoError(ts.s.T(), err)
|
2025-02-14 22:19:34 +00:00
|
|
|
if e, ok := v.(fleet.Errorer); ok {
|
2025-02-03 17:23:26 +00:00
|
|
|
require.NoError(ts.s.T(), e.Error())
|
2024-08-30 17:13:25 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-09-15 19:27:53 +00:00
|
|
|
func (ts *withServer) getTestAdminToken() string {
|
|
|
|
|
testUser := testUsers["admin1"]
|
|
|
|
|
|
2022-03-08 16:27:38 +00:00
|
|
|
// because the login endpoint is rate-limited, use the cached admin token
|
|
|
|
|
// if available (if for some reason a test needs to logout the admin user,
|
|
|
|
|
// then set cachedAdminToken = "" so that a new token is retrieved).
|
|
|
|
|
if ts.cachedAdminToken == "" {
|
|
|
|
|
ts.cachedAdminToken = ts.getTestToken(testUser.Email, testUser.PlaintextPassword)
|
|
|
|
|
}
|
|
|
|
|
return ts.cachedAdminToken
|
2021-09-20 14:00:57 +00:00
|
|
|
}
|
|
|
|
|
|
2024-12-10 21:32:51 +00:00
|
|
|
func (ts *withServer) setTokenForTest(t *testing.T, email, password string) {
|
|
|
|
|
oldToken := ts.token
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
ts.token = oldToken
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ts.token = ts.getCachedUserToken(email, password)
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-12 19:11:04 +00:00
|
|
|
// getCachedUserToken returns the cached auth token for the given test user email.
|
|
|
|
|
// If it's not found, then a login request is performed and the token cached.
|
|
|
|
|
func (ts *withServer) getCachedUserToken(email, password string) string {
|
|
|
|
|
ts.cachedTokensMu.Lock()
|
|
|
|
|
defer ts.cachedTokensMu.Unlock()
|
|
|
|
|
|
2023-04-17 15:08:55 +00:00
|
|
|
if ts.cachedTokens == nil {
|
|
|
|
|
ts.cachedTokens = make(map[string]string)
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-12 19:11:04 +00:00
|
|
|
token, ok := ts.cachedTokens[email]
|
|
|
|
|
if !ok {
|
|
|
|
|
token = ts.getTestToken(email, password)
|
|
|
|
|
ts.cachedTokens[email] = token
|
|
|
|
|
}
|
|
|
|
|
return token
|
|
|
|
|
}
|
|
|
|
|
|
2021-09-20 14:00:57 +00:00
|
|
|
func (ts *withServer) getTestToken(email string, password string) string {
|
2025-03-04 18:04:25 +00:00
|
|
|
return GetToken(ts.s.T(), email, password, ts.server.URL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func GetToken(t *testing.T, email string, password string, serverURL string) string {
|
2025-04-10 19:08:45 +00:00
|
|
|
params := contract.LoginRequest{
|
2021-09-20 14:00:57 +00:00
|
|
|
Email: email,
|
|
|
|
|
Password: password,
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
j, err := json.Marshal(¶ms)
|
2025-03-04 18:04:25 +00:00
|
|
|
require.NoError(t, err)
|
2021-09-15 19:27:53 +00:00
|
|
|
|
|
|
|
|
requestBody := io.NopCloser(bytes.NewBuffer(j))
|
2025-03-04 18:04:25 +00:00
|
|
|
resp, err := http.Post(serverURL+"/api/latest/fleet/login", "application/json", requestBody)
|
|
|
|
|
require.NoError(t, err)
|
2021-09-15 19:27:53 +00:00
|
|
|
defer resp.Body.Close()
|
2025-03-04 18:04:25 +00:00
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
2021-09-15 19:27:53 +00:00
|
|
|
|
2022-04-19 13:35:53 +00:00
|
|
|
jsn := struct {
|
2021-09-15 19:27:53 +00:00
|
|
|
User *fleet.User `json:"user"`
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
Err []map[string]string `json:"errors,omitempty"`
|
|
|
|
|
}{}
|
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&jsn)
|
2025-03-04 18:04:25 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
require.Len(t, jsn.Err, 0)
|
2021-09-15 19:27:53 +00:00
|
|
|
|
|
|
|
|
return jsn.Token
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) applyConfig(spec []byte) {
|
|
|
|
|
var appConfigSpec interface{}
|
|
|
|
|
err := yaml.Unmarshal(spec, &appConfigSpec)
|
|
|
|
|
require.NoError(ts.s.T(), err)
|
|
|
|
|
|
2022-04-05 15:35:53 +00:00
|
|
|
ts.Do("PATCH", "/api/latest/fleet/config", appConfigSpec, http.StatusOK)
|
2021-09-15 19:27:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) getConfig() *appConfigResponse {
|
|
|
|
|
var responseBody *appConfigResponse
|
2022-04-05 15:35:53 +00:00
|
|
|
ts.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &responseBody)
|
2021-09-15 19:27:53 +00:00
|
|
|
return responseBody
|
|
|
|
|
}
|
2022-08-15 17:42:33 +00:00
|
|
|
|
2023-10-04 20:02:55 +00:00
|
|
|
func (ts *withServer) applyTeamSpec(yamlSpec []byte) {
|
|
|
|
|
var teamSpec any
|
|
|
|
|
err := yaml.Unmarshal(yamlSpec, &teamSpec)
|
|
|
|
|
require.NoError(ts.s.T(), err)
|
|
|
|
|
|
|
|
|
|
specsReq := map[string]any{
|
|
|
|
|
"specs": []any{teamSpec},
|
|
|
|
|
}
|
|
|
|
|
ts.Do("POST", "/api/latest/fleet/spec/teams", specsReq, http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-07 18:13:46 +00:00
|
|
|
func (ts *withServer) LoginSSOUser(username, password string) string {
|
2022-08-15 17:42:33 +00:00
|
|
|
t := ts.s.T()
|
2025-07-07 18:13:46 +00:00
|
|
|
res := ts.loginSSOUser(username, password, "/api/v1/fleet/sso", http.StatusOK)
|
2023-04-27 12:43:20 +00:00
|
|
|
defer res.Body.Close()
|
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
|
|
|
require.NoError(t, err)
|
2025-07-07 18:13:46 +00:00
|
|
|
return string(body)
|
2023-04-27 12:43:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) LoginMDMSSOUser(username, password string) *http.Response {
|
2025-07-07 18:13:46 +00:00
|
|
|
res := ts.loginSSOUser(username, password, "/api/v1/fleet/mdm/sso", http.StatusSeeOther)
|
2023-04-27 12:43:20 +00:00
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-15 19:02:11 +00:00
|
|
|
func (ts *withServer) LoginAccountDrivenEnrollUser(username, password string) *http.Response {
|
2025-08-18 16:31:53 +00:00
|
|
|
requestParams := initiateMDMSSORequest{
|
2025-07-15 19:02:11 +00:00
|
|
|
Initiator: "account_driven_enroll",
|
|
|
|
|
UserIdentifier: username + "@example.com",
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(requestParams)
|
|
|
|
|
require.NoError(ts.s.T(), err)
|
|
|
|
|
res := ts.loginSSOUserWithBody(username, password, "/api/v1/fleet/mdm/sso", http.StatusSeeOther, body)
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-07 18:13:46 +00:00
|
|
|
func (ts *withServer) LoginSSOUserIDPInitiated(username, password, entityID string) string {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
res := ts.loginSSOUserIDPInitiated(
|
|
|
|
|
username, password,
|
|
|
|
|
"/api/v1/fleet/sso",
|
2026-03-18 18:58:58 +00:00
|
|
|
fmt.Sprintf("%s?spentityid=%s", testSAMLIDPSSOURL, entityID),
|
2025-07-07 18:13:46 +00:00
|
|
|
http.StatusOK,
|
|
|
|
|
)
|
|
|
|
|
defer res.Body.Close()
|
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
return string(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) doWithClient(
|
|
|
|
|
client *http.Client,
|
|
|
|
|
verb string, path string, rawBytes []byte,
|
|
|
|
|
expectedStatusCode int, headers map[string]string,
|
|
|
|
|
queryParams ...string,
|
|
|
|
|
) *http.Response {
|
|
|
|
|
return fleet_httptest.DoHTTPReq(
|
|
|
|
|
ts.s.T(),
|
|
|
|
|
client,
|
|
|
|
|
decodeJSON,
|
|
|
|
|
verb,
|
|
|
|
|
rawBytes,
|
|
|
|
|
ts.server.URL+path,
|
|
|
|
|
headers,
|
|
|
|
|
expectedStatusCode,
|
|
|
|
|
queryParams...,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) loginSSOUser(username, password string, basePath string, callbackStatus int) *http.Response {
|
2025-07-15 19:02:11 +00:00
|
|
|
return ts.loginSSOUserWithBody(username, password, basePath, callbackStatus, []byte(`{}`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) loginSSOUserWithBody(username, password string, basePath string, callbackStatus int, requestBody []byte) *http.Response {
|
2023-04-27 12:43:20 +00:00
|
|
|
t := ts.s.T()
|
2022-08-15 17:42:33 +00:00
|
|
|
|
|
|
|
|
if _, ok := os.LookupEnv("SAML_IDP_TEST"); !ok {
|
|
|
|
|
t.Skip("SSO tests are disabled")
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-07 18:13:46 +00:00
|
|
|
cookieSecure = false
|
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
client := fleethttp.NewClient(
|
|
|
|
|
fleethttp.WithFollowRedir(false),
|
|
|
|
|
fleethttp.WithCookieJar(jar),
|
|
|
|
|
)
|
|
|
|
|
|
2022-08-15 17:42:33 +00:00
|
|
|
var resIni initiateSSOResponse
|
2025-07-15 19:02:11 +00:00
|
|
|
httpResponse := ts.doWithClient(client, "POST", basePath, requestBody, http.StatusOK, nil)
|
2025-07-07 18:13:46 +00:00
|
|
|
err = json.NewDecoder(httpResponse.Body).Decode(&resIni)
|
|
|
|
|
require.NoError(ts.s.T(), err)
|
|
|
|
|
require.NoError(ts.s.T(), resIni.Error())
|
|
|
|
|
|
|
|
|
|
resp, err := client.Get(resIni.URL)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
// From the redirect Location header we can get the AuthState and the URL to
|
|
|
|
|
// which we submit the credentials
|
|
|
|
|
parsed, err := url.Parse(resp.Header.Get("Location"))
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
data := url.Values{
|
|
|
|
|
"username": {username},
|
|
|
|
|
"password": {password},
|
|
|
|
|
"AuthState": {parsed.Query().Get("AuthState")},
|
|
|
|
|
}
|
|
|
|
|
resp, err = client.PostForm(parsed.Scheme+"://"+parsed.Host+parsed.Path, data)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
// The response is an HTML form, we can extract the base64-encoded response
|
|
|
|
|
// to submit to the Fleet server from here
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
re := regexp.MustCompile(`name="SAMLResponse" value="([^\s]*)" />`)
|
|
|
|
|
matches := re.FindSubmatch(body)
|
|
|
|
|
require.NotEmptyf(t, matches, "callback HTML doesn't contain a SAMLResponse value, got body: %s", body)
|
|
|
|
|
samlResponse := string(matches[1])
|
|
|
|
|
|
2025-07-15 19:02:11 +00:00
|
|
|
callbackUrl := basePath + "/callback"
|
|
|
|
|
res := ts.doWithClient(client, "POST", callbackUrl, nil, callbackStatus, nil, "SAMLResponse", samlResponse)
|
2025-07-07 18:13:46 +00:00
|
|
|
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) loginSSOUserIDPInitiated(
|
|
|
|
|
username, password string,
|
|
|
|
|
callbackBasePath string,
|
|
|
|
|
idpURL string,
|
|
|
|
|
callbackStatus int,
|
|
|
|
|
) *http.Response {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
|
|
|
|
|
if _, ok := os.LookupEnv("SAML_IDP_TEST"); !ok {
|
|
|
|
|
t.Skip("SSO tests are disabled")
|
|
|
|
|
}
|
2022-08-15 17:42:33 +00:00
|
|
|
|
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
client := fleethttp.NewClient(
|
|
|
|
|
fleethttp.WithFollowRedir(false),
|
|
|
|
|
fleethttp.WithCookieJar(jar),
|
|
|
|
|
)
|
|
|
|
|
|
2025-07-07 18:13:46 +00:00
|
|
|
resp, err := client.Get(idpURL)
|
2022-08-15 17:42:33 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
// From the redirect Location header we can get the AuthState and the URL to
|
|
|
|
|
// which we submit the credentials
|
|
|
|
|
parsed, err := url.Parse(resp.Header.Get("Location"))
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
data := url.Values{
|
|
|
|
|
"username": {username},
|
|
|
|
|
"password": {password},
|
|
|
|
|
"AuthState": {parsed.Query().Get("AuthState")},
|
|
|
|
|
}
|
|
|
|
|
resp, err = client.PostForm(parsed.Scheme+"://"+parsed.Host+parsed.Path, data)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
// The response is an HTML form, we can extract the base64-encoded response
|
|
|
|
|
// to submit to the Fleet server from here
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
require.NoError(t, err)
|
2025-07-07 18:13:46 +00:00
|
|
|
re := regexp.MustCompile(`name="SAMLResponse" value="([^\s]*)" />`)
|
2022-08-15 17:42:33 +00:00
|
|
|
matches := re.FindSubmatch(body)
|
|
|
|
|
require.NotEmptyf(t, matches, "callback HTML doesn't contain a SAMLResponse value, got body: %s", body)
|
|
|
|
|
rawSSOResp := string(matches[1])
|
|
|
|
|
|
|
|
|
|
q := url.QueryEscape(rawSSOResp)
|
2025-07-07 18:13:46 +00:00
|
|
|
res := ts.DoRawNoAuth("POST", callbackBasePath+"/callback?SAMLResponse="+q, nil, callbackStatus)
|
2022-08-15 17:42:33 +00:00
|
|
|
|
2025-07-07 18:13:46 +00:00
|
|
|
return res
|
2022-08-15 17:42:33 +00:00
|
|
|
}
|
2023-02-08 23:20:23 +00:00
|
|
|
|
2026-03-03 07:01:42 +00:00
|
|
|
type listActivitiesResponse struct {
|
|
|
|
|
Meta *fleet.PaginationMetadata `json:"meta"`
|
|
|
|
|
Activities []*fleet.Activity `json:"activities"`
|
|
|
|
|
Err error `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r listActivitiesResponse) Error() error { return r.Err }
|
|
|
|
|
|
2025-02-19 19:55:20 +00:00
|
|
|
func (ts *withServer) lastActivityMatches(name, details string, id uint) uint {
|
|
|
|
|
return ts.lastActivityMatchesExtended(name, details, id, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-08 23:20:23 +00:00
|
|
|
// gets the latest activity and checks that it matches any provided properties.
|
|
|
|
|
// empty string or 0 id means do not check that property. It returns the ID of that
|
|
|
|
|
// latest activity.
|
2025-02-19 19:55:20 +00:00
|
|
|
func (ts *withServer) lastActivityMatchesExtended(name, details string, id uint, fleetInitiated *bool) uint {
|
2023-02-08 23:20:23 +00:00
|
|
|
t := ts.s.T()
|
|
|
|
|
var listActivities listActivitiesResponse
|
|
|
|
|
ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "1")
|
|
|
|
|
require.True(t, len(listActivities.Activities) > 0)
|
|
|
|
|
|
|
|
|
|
act := listActivities.Activities[0]
|
|
|
|
|
if name != "" {
|
|
|
|
|
assert.Equal(t, name, act.Type)
|
|
|
|
|
}
|
|
|
|
|
if details != "" {
|
|
|
|
|
require.NotNil(t, act.Details)
|
|
|
|
|
assert.JSONEq(t, details, string(*act.Details))
|
|
|
|
|
}
|
|
|
|
|
if id > 0 {
|
|
|
|
|
assert.Equal(t, id, act.ID)
|
|
|
|
|
}
|
2025-02-19 19:55:20 +00:00
|
|
|
if fleetInitiated != nil {
|
|
|
|
|
assert.Equal(t, *fleetInitiated, act.FleetInitiated)
|
|
|
|
|
}
|
2023-02-08 23:20:23 +00:00
|
|
|
return act.ID
|
|
|
|
|
}
|
2023-03-08 13:31:53 +00:00
|
|
|
|
2026-04-07 20:23:59 +00:00
|
|
|
func (ts *withServer) lastHostActivityMatches(hostID uint, name, details string, id uint) uint {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
var listActivities listActivitiesResponse
|
|
|
|
|
ts.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", hostID), nil, http.StatusOK, &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10")
|
|
|
|
|
require.NotEmpty(t, listActivities.Activities)
|
|
|
|
|
|
|
|
|
|
act := listActivities.Activities[0]
|
|
|
|
|
|
|
|
|
|
if name != "" {
|
|
|
|
|
assert.Equal(t, name, act.Type)
|
|
|
|
|
}
|
|
|
|
|
if details != "" {
|
|
|
|
|
require.NotNil(t, act.Details)
|
|
|
|
|
assert.JSONEq(t, details, string(*act.Details))
|
|
|
|
|
}
|
|
|
|
|
if id > 0 {
|
|
|
|
|
assert.Equal(t, id, act.ID)
|
|
|
|
|
}
|
|
|
|
|
return act.ID
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-08 13:31:53 +00:00
|
|
|
// gets the latest activity with the specified type name and checks that it
|
|
|
|
|
// matches any provided properties. empty string or 0 id means do not check
|
|
|
|
|
// that property. It returns the ID of that latest activity.
|
|
|
|
|
//
|
|
|
|
|
// The difference with lastActivityMatches is that the asserted activity does
|
|
|
|
|
// not need to be the very last one, it will look for the last one of this
|
|
|
|
|
// specified type, which must be in one of the last 10 activities otherwise the
|
|
|
|
|
// test is failed.
|
|
|
|
|
func (ts *withServer) lastActivityOfTypeMatches(name, details string, id uint) uint {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
|
|
|
|
|
var listActivities listActivitiesResponse
|
|
|
|
|
ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK,
|
|
|
|
|
&listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10")
|
|
|
|
|
require.True(t, len(listActivities.Activities) > 0)
|
|
|
|
|
|
|
|
|
|
for _, act := range listActivities.Activities {
|
|
|
|
|
if act.Type == name {
|
|
|
|
|
if details != "" {
|
|
|
|
|
require.NotNil(t, act.Details)
|
|
|
|
|
assert.JSONEq(t, details, string(*act.Details))
|
|
|
|
|
}
|
|
|
|
|
if id > 0 {
|
|
|
|
|
assert.Equal(t, id, act.ID)
|
|
|
|
|
}
|
|
|
|
|
return act.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
t.Fatalf("no activity of type %s found in the last %d activities", name, len(listActivities.Activities))
|
|
|
|
|
return 0
|
|
|
|
|
}
|
2024-08-30 21:00:35 +00:00
|
|
|
|
|
|
|
|
func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id uint) {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
|
|
|
|
|
var listActivities listActivitiesResponse
|
|
|
|
|
ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK,
|
|
|
|
|
&listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10")
|
|
|
|
|
require.True(t, len(listActivities.Activities) > 0)
|
|
|
|
|
|
|
|
|
|
for _, act := range listActivities.Activities {
|
|
|
|
|
if act.Type == name {
|
|
|
|
|
if details != "" {
|
|
|
|
|
require.NotNil(t, act.Details)
|
|
|
|
|
assert.NotEqual(t, details, string(*act.Details))
|
|
|
|
|
}
|
|
|
|
|
if id > 0 {
|
|
|
|
|
assert.NotEqual(t, id, act.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-10-14 20:41:06 +00:00
|
|
|
|
2026-01-23 13:42:09 +00:00
|
|
|
// listActivities retrieves all activities via the HTTP API endpoint.
|
|
|
|
|
func (ts *withServer) listActivities() []*fleet.Activity {
|
|
|
|
|
t := ts.s.T()
|
|
|
|
|
var resp listActivitiesResponse
|
|
|
|
|
ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &resp,
|
2026-02-13 19:32:09 +00:00
|
|
|
"order_key", "a.id", "order_direction", "asc", "per_page", "10000")
|
2026-01-23 13:42:09 +00:00
|
|
|
require.NotNil(t, resp.Activities)
|
|
|
|
|
return resp.Activities
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-14 20:41:06 +00:00
|
|
|
func (ts *withServer) uploadSoftwareInstaller(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
payload *fleet.UploadSoftwareInstallerPayload,
|
|
|
|
|
expectedStatus int,
|
|
|
|
|
expectedError string,
|
2024-12-31 00:46:42 +00:00
|
|
|
) {
|
|
|
|
|
ts.uploadSoftwareInstallerWithErrorNameReason(t, payload, expectedStatus, expectedError, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *withServer) uploadSoftwareInstallerWithErrorNameReason(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
payload *fleet.UploadSoftwareInstallerPayload,
|
|
|
|
|
expectedStatus int,
|
|
|
|
|
expectedErrorReason string,
|
|
|
|
|
expectedErrorName string,
|
2024-10-14 20:41:06 +00:00
|
|
|
) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
2025-10-16 00:43:58 +00:00
|
|
|
// Determine which file to use: either provided by test or opened from testdata
|
|
|
|
|
var installerFile io.Reader
|
|
|
|
|
if payload.InstallerFile == nil {
|
|
|
|
|
// Open file from testdata and close it when done
|
|
|
|
|
tfr, err := fleet.NewKeepFileReader(filepath.Join("testdata", "software-installers", payload.Filename))
|
|
|
|
|
// Try the test installers in the pkg/file testdata (to reduce clutter/copies).
|
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
var err2 error
|
|
|
|
|
tfr, err2 = fleet.NewKeepFileReader(filepath.Join("..", "..", "pkg", "file", "testdata", "software-installers", payload.Filename))
|
|
|
|
|
if err2 == nil {
|
|
|
|
|
err = nil
|
|
|
|
|
}
|
2024-12-27 18:10:28 +00:00
|
|
|
}
|
2025-10-16 00:43:58 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
defer tfr.Close()
|
|
|
|
|
installerFile = tfr
|
|
|
|
|
} else {
|
|
|
|
|
// Use the file provided by the test
|
|
|
|
|
installerFile = payload.InstallerFile
|
2024-12-27 18:10:28 +00:00
|
|
|
}
|
2024-10-14 20:41:06 +00:00
|
|
|
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
w := multipart.NewWriter(&b)
|
|
|
|
|
|
|
|
|
|
// add the software field
|
|
|
|
|
fw, err := w.CreateFormFile("software", payload.Filename)
|
|
|
|
|
require.NoError(t, err)
|
2025-10-16 00:43:58 +00:00
|
|
|
n, err := io.Copy(fw, installerFile)
|
2024-10-14 20:41:06 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
require.NotZero(t, n)
|
|
|
|
|
|
|
|
|
|
// add the team_id field
|
|
|
|
|
if payload.TeamID != nil {
|
|
|
|
|
require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID)))
|
|
|
|
|
}
|
|
|
|
|
// add the remaining fields
|
|
|
|
|
require.NoError(t, w.WriteField("install_script", payload.InstallScript))
|
|
|
|
|
require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery))
|
|
|
|
|
require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript))
|
|
|
|
|
require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript))
|
|
|
|
|
if payload.SelfService {
|
|
|
|
|
require.NoError(t, w.WriteField("self_service", "true"))
|
|
|
|
|
}
|
2024-12-17 00:17:13 +00:00
|
|
|
if payload.LabelsIncludeAny != nil {
|
2024-12-20 16:49:28 +00:00
|
|
|
for _, l := range payload.LabelsIncludeAny {
|
|
|
|
|
require.NoError(t, w.WriteField("labels_include_any", l))
|
|
|
|
|
}
|
2024-12-17 00:17:13 +00:00
|
|
|
}
|
|
|
|
|
if payload.LabelsExcludeAny != nil {
|
2024-12-20 16:49:28 +00:00
|
|
|
for _, l := range payload.LabelsExcludeAny {
|
|
|
|
|
require.NoError(t, w.WriteField("labels_exclude_any", l))
|
|
|
|
|
}
|
2024-12-17 00:17:13 +00:00
|
|
|
}
|
Backend: Support labels_include_all for installers/apps (#41324)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40721
# Checklist for submitter
If some of the following don't apply, delete the relevant line.
- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects
## Testing
- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)
- [ ] QA'd all new/changed functionality manually
I (Martin) did test `labels_include_all` for FMA, custom installer, IPA
and VPP apps, and it seemed to all work great for gitops apply and
gitops generate, **except for VPP apps** which seem to have 2 important
pre-existing bugs, see
https://github.com/fleetdm/fleet/issues/40723#issuecomment-4041780707
## New Fleet configuration settings
- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled
---------
Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com>
2026-03-18 17:27:53 +00:00
|
|
|
if payload.LabelsIncludeAll != nil {
|
|
|
|
|
for _, l := range payload.LabelsIncludeAll {
|
|
|
|
|
require.NoError(t, w.WriteField("labels_include_all", l))
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-12-27 18:10:28 +00:00
|
|
|
if payload.AutomaticInstall {
|
|
|
|
|
require.NoError(t, w.WriteField("automatic_install", "true"))
|
|
|
|
|
}
|
2024-10-14 20:41:06 +00:00
|
|
|
|
|
|
|
|
w.Close()
|
|
|
|
|
|
|
|
|
|
headers := map[string]string{
|
|
|
|
|
"Content-Type": w.FormDataContentType(),
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
"Authorization": fmt.Sprintf("Bearer %s", ts.token),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r := ts.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers)
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
|
|
2024-12-31 00:46:42 +00:00
|
|
|
if expectedErrorReason != "" || expectedErrorName != "" {
|
|
|
|
|
errName, errReason := extractServerErrorNameReason(r.Body)
|
|
|
|
|
if expectedErrorName != "" {
|
|
|
|
|
require.Equal(t, expectedErrorName, errName)
|
|
|
|
|
}
|
|
|
|
|
if expectedErrorReason != "" {
|
|
|
|
|
require.Contains(t, errReason, expectedErrorReason)
|
|
|
|
|
}
|
2024-10-14 20:41:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
2024-12-24 17:30:46 +00:00
|
|
|
|
|
|
|
|
func (ts *withServer) updateSoftwareInstaller(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
payload *fleet.UpdateSoftwareInstallerPayload,
|
|
|
|
|
expectedStatus int,
|
|
|
|
|
expectedError string,
|
|
|
|
|
) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
w := multipart.NewWriter(&b)
|
|
|
|
|
|
|
|
|
|
// add the software field
|
2025-05-02 15:41:26 +00:00
|
|
|
if payload.Filename != "" && payload.InstallerFile != nil {
|
2025-04-29 22:53:52 +00:00
|
|
|
fw, err := w.CreateFormFile("software", payload.Filename)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
n, err := io.Copy(fw, payload.InstallerFile)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
require.NotZero(t, n)
|
|
|
|
|
}
|
2024-12-24 17:30:46 +00:00
|
|
|
|
|
|
|
|
// add the team_id field
|
|
|
|
|
var tmID uint
|
|
|
|
|
if payload.TeamID != nil {
|
|
|
|
|
tmID = *payload.TeamID
|
|
|
|
|
}
|
|
|
|
|
require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", tmID)))
|
|
|
|
|
// add the remaining fields
|
|
|
|
|
if payload.InstallScript != nil {
|
|
|
|
|
require.NoError(t, w.WriteField("install_script", *payload.InstallScript))
|
|
|
|
|
}
|
|
|
|
|
if payload.PreInstallQuery != nil {
|
|
|
|
|
require.NoError(t, w.WriteField("pre_install_query", *payload.PreInstallQuery))
|
|
|
|
|
}
|
|
|
|
|
if payload.PostInstallScript != nil {
|
|
|
|
|
require.NoError(t, w.WriteField("post_install_script", *payload.PostInstallScript))
|
|
|
|
|
}
|
|
|
|
|
if payload.UninstallScript != nil {
|
|
|
|
|
require.NoError(t, w.WriteField("uninstall_script", *payload.UninstallScript))
|
|
|
|
|
}
|
|
|
|
|
if payload.SelfService != nil {
|
|
|
|
|
if *payload.SelfService {
|
|
|
|
|
require.NoError(t, w.WriteField("self_service", "true"))
|
|
|
|
|
} else {
|
|
|
|
|
require.NoError(t, w.WriteField("self_service", "false"))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-01-06 17:18:16 +00:00
|
|
|
if payload.LabelsIncludeAny != nil {
|
|
|
|
|
for _, l := range payload.LabelsIncludeAny {
|
|
|
|
|
require.NoError(t, w.WriteField("labels_include_any", l))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if payload.LabelsExcludeAny != nil {
|
|
|
|
|
for _, l := range payload.LabelsExcludeAny {
|
|
|
|
|
require.NoError(t, w.WriteField("labels_exclude_any", l))
|
|
|
|
|
}
|
|
|
|
|
}
|
Backend: Support labels_include_all for installers/apps (#41324)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40721
# Checklist for submitter
If some of the following don't apply, delete the relevant line.
- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects
## Testing
- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)
- [ ] QA'd all new/changed functionality manually
I (Martin) did test `labels_include_all` for FMA, custom installer, IPA
and VPP apps, and it seemed to all work great for gitops apply and
gitops generate, **except for VPP apps** which seem to have 2 important
pre-existing bugs, see
https://github.com/fleetdm/fleet/issues/40723#issuecomment-4041780707
## New Fleet configuration settings
- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled
---------
Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com>
2026-03-18 17:27:53 +00:00
|
|
|
if payload.LabelsIncludeAll != nil {
|
|
|
|
|
for _, l := range payload.LabelsIncludeAll {
|
|
|
|
|
require.NoError(t, w.WriteField("labels_include_all", l))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-02 15:41:26 +00:00
|
|
|
if payload.Categories != nil {
|
|
|
|
|
for _, c := range payload.Categories {
|
|
|
|
|
require.NoError(t, w.WriteField("categories", c))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-19 00:23:18 +00:00
|
|
|
if payload.DisplayName != nil {
|
|
|
|
|
require.NoError(t, w.WriteField("display_name", *payload.DisplayName))
|
|
|
|
|
}
|
2024-12-24 17:30:46 +00:00
|
|
|
|
|
|
|
|
w.Close()
|
|
|
|
|
|
|
|
|
|
headers := map[string]string{
|
|
|
|
|
"Content-Type": w.FormDataContentType(),
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
"Authorization": fmt.Sprintf("Bearer %s", ts.token),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r := ts.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", payload.TitleID), b.Bytes(), expectedStatus, headers)
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
|
|
|
|
|
|
if expectedError != "" {
|
|
|
|
|
errMsg := extractServerErrorText(r.Body)
|
|
|
|
|
require.Contains(t, errMsg, expectedError)
|
2025-11-17 16:23:35 +00:00
|
|
|
return
|
2024-12-24 17:30:46 +00:00
|
|
|
}
|
2025-11-17 16:23:35 +00:00
|
|
|
|
|
|
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
var resp getSoftwareInstallerResponse
|
|
|
|
|
require.NoError(t, json.Unmarshal(bodyBytes, &resp))
|
|
|
|
|
|
2025-11-19 00:23:18 +00:00
|
|
|
if payload.DisplayName != nil {
|
|
|
|
|
assert.Equal(t, *payload.DisplayName, resp.SoftwareInstaller.DisplayName)
|
|
|
|
|
}
|
2024-12-24 17:30:46 +00:00
|
|
|
}
|