mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
New API endpoint for host reports (41534)
Resolves #41534 Added GET /api/v1/fleet/hosts/{id}/reports endpoint (also accessible as /hosts/{id}/queries) that lists the query reports associated with a specific host.
This commit is contained in:
parent
701b4a7247
commit
b226eb56d0
13 changed files with 1327 additions and 0 deletions
1
changes/41534-host-details-reports-api-end-point
Normal file
1
changes/41534-host-details-reports-api-end-point
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added GET /api/v1/fleet/hosts/{id}/reports endpoint (also accessible as /hosts/{id}/queries) that lists the query reports associated with a specific host.
|
||||
|
|
@ -2,12 +2,16 @@ package mysql
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
|
@ -265,3 +269,225 @@ func (ds *Datastore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryR
|
|||
|
||||
return queryCounts, nil
|
||||
}
|
||||
|
||||
// hostReportAllowedOrderKeys defines the allowed order keys for ListHostReports.
|
||||
// The last_fetched entry is overridden dynamically in ListHostReports with a
|
||||
// direction-aware COALESCE sentinel so that NULLs sort last in both ASC and DESC
|
||||
// and the expression remains a single column (required for cursor pagination).
|
||||
var hostReportAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
|
||||
"name": "q.name",
|
||||
"last_fetched": "qr_stats.last_result_fetched",
|
||||
}
|
||||
|
||||
// hostReportRow is a scan target for the paginated query list in ListHostReports.
|
||||
type hostReportRow struct {
|
||||
QueryID uint `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Description string `db:"description"`
|
||||
LastResultFetched sql.NullTime `db:"last_result_fetched"`
|
||||
DiscardData bool `db:"discard_data"`
|
||||
LoggingType string `db:"logging_type"`
|
||||
}
|
||||
|
||||
// ListHostReports returns reports associated with a host, applying
|
||||
// the provided filtering, sorting, and pagination options. maxQueryReportRows
|
||||
// is the configured report cap; a query whose total result count (across all
|
||||
// hosts) meets or exceeds this value is considered clipped.
|
||||
//
|
||||
// The implementation uses three queries to avoid N correlated subqueries:
|
||||
// 1. A paginated query list (no joins to query_results).
|
||||
// 2. A single aggregation over query_results for the page's query IDs to
|
||||
// compute per-query and per-host counts and the most recent fetch time.
|
||||
// 3. A window-function query to fetch the most recent result row for
|
||||
// each query on the page.
|
||||
func (ds *Datastore) ListHostReports(
|
||||
ctx context.Context,
|
||||
hostID uint,
|
||||
teamID *uint,
|
||||
opts fleet.ListHostReportsOptions,
|
||||
maxQueryReportRows int,
|
||||
) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
|
||||
// We only care about saved queries
|
||||
whereClause := "WHERE q.saved = 1"
|
||||
var whereArgs []any
|
||||
|
||||
// We also want to show queries that have not run yet, so we need
|
||||
// to figure out which queries are associated with the host based
|
||||
// on Team membership.
|
||||
switch {
|
||||
case teamID != nil:
|
||||
whereArgs = append(whereArgs, *teamID)
|
||||
whereClause += " AND (q.team_id IS NULL OR q.team_id = ?)"
|
||||
default:
|
||||
whereClause += " AND q.team_id IS NULL"
|
||||
}
|
||||
|
||||
// By default, only include queries that store results (discard_data=0 AND
|
||||
// logging_type='snapshot'). When IncludeReportsDontStoreResults is set,
|
||||
// all queries are returned regardless of their storage settings.
|
||||
if !opts.IncludeReportsDontStoreResults {
|
||||
whereClause += " AND q.discard_data = 0 AND q.logging_type = 'snapshot'"
|
||||
}
|
||||
|
||||
matchQuery := strings.TrimSpace(opts.ListOptions.MatchQuery)
|
||||
if matchQuery != "" {
|
||||
whereClause, whereArgs = searchLike(whereClause, whereArgs, matchQuery, "q.name")
|
||||
}
|
||||
|
||||
countStmt := "SELECT COUNT(*) FROM queries q " + whereClause
|
||||
|
||||
// The list query LEFT JOINs a per-host aggregation so that last_result_fetched
|
||||
// is available for ORDER BY. The JOIN uses hostID as its only argument, which
|
||||
// must be prepended before the WHERE-clause args.
|
||||
listStmt := `
|
||||
SELECT q.id, q.name, q.description, q.discard_data, q.logging_type, qr_stats.last_result_fetched
|
||||
FROM queries q
|
||||
LEFT JOIN (
|
||||
SELECT query_id, MAX(last_fetched) AS last_result_fetched
|
||||
FROM query_results
|
||||
WHERE host_id = ?
|
||||
GROUP BY query_id
|
||||
) qr_stats ON q.id = qr_stats.query_id
|
||||
` + whereClause
|
||||
listArgs := append([]any{hostID}, whereArgs...)
|
||||
|
||||
// For last_fetched, replace the static allowlist entry with a direction-aware
|
||||
// COALESCE so that NULLs sort last in both ASC and DESC while keeping the
|
||||
// expression as a single column (required for cursor WHERE comparison).
|
||||
// A secondary sort by q.id breaks timestamp ties deterministically.
|
||||
allowedKeys := hostReportAllowedOrderKeys
|
||||
if opts.ListOptions.OrderKey == "last_fetched" {
|
||||
sentinel := "'9999-12-31 23:59:59'" // NULLs → max, sort last in ASC
|
||||
if opts.ListOptions.OrderDirection == fleet.OrderDescending {
|
||||
sentinel = "'0001-01-01 00:00:00'" // NULLs → min, sort last in DESC
|
||||
}
|
||||
allowedKeys = make(common_mysql.OrderKeyAllowlist, len(hostReportAllowedOrderKeys)+1)
|
||||
maps.Copy(allowedKeys, hostReportAllowedOrderKeys)
|
||||
allowedKeys["last_fetched"] = fmt.Sprintf("COALESCE(qr_stats.last_result_fetched, %s)", sentinel)
|
||||
allowedKeys["id"] = "q.id"
|
||||
opts.ListOptions.TestSecondaryOrderKey = "id"
|
||||
}
|
||||
|
||||
pagedStmt, pagedArgs, err := appendListOptionsWithCursorToSQLSecure(listStmt, listArgs, &opts.ListOptions, allowedKeys)
|
||||
if err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "apply list options for host reports")
|
||||
}
|
||||
|
||||
dbReader := ds.reader(ctx)
|
||||
|
||||
var queryRows []hostReportRow
|
||||
if err := sqlx.SelectContext(ctx, dbReader, &queryRows, pagedStmt, pagedArgs...); err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "listing host reports")
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := sqlx.GetContext(ctx, dbReader, &total, countStmt, whereArgs...); err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "counting host reports")
|
||||
}
|
||||
|
||||
metadata := &fleet.PaginationMetadata{HasPreviousResults: opts.ListOptions.Page > 0}
|
||||
if len(queryRows) > int(opts.ListOptions.PerPage) { //nolint:gosec // dismiss G115
|
||||
metadata.HasNextResults = true
|
||||
queryRows = queryRows[:len(queryRows)-1]
|
||||
}
|
||||
|
||||
if len(queryRows) == 0 {
|
||||
return []*fleet.HostReport{}, total, metadata, nil
|
||||
}
|
||||
|
||||
// Collect IDs for the current page.
|
||||
queryIDs := make([]uint, 0, len(queryRows))
|
||||
for _, r := range queryRows {
|
||||
queryIDs = append(queryIDs, r.QueryID)
|
||||
}
|
||||
|
||||
// Batch-fetch result stats for the page in a single aggregation query.
|
||||
// n_query_results counts all hosts (used for report_clipped).
|
||||
// n_host_results is scoped to the current host (used for has_more_results).
|
||||
// last_result_fetched is already available from the list query LEFT JOIN.
|
||||
type statsRow struct {
|
||||
QueryID uint `db:"query_id"`
|
||||
NQueryResults int `db:"n_query_results"`
|
||||
NHostResults int `db:"n_host_results"`
|
||||
}
|
||||
statsStmt, statsArgs, err := sqlx.In(`
|
||||
SELECT
|
||||
query_id,
|
||||
COUNT(data) AS n_query_results,
|
||||
SUM(CASE WHEN host_id = ? AND data IS NOT NULL THEN 1 ELSE 0 END) AS n_host_results
|
||||
FROM query_results
|
||||
WHERE query_id IN (?)
|
||||
GROUP BY query_id
|
||||
`, hostID, queryIDs)
|
||||
if err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "building stats query for host reports")
|
||||
}
|
||||
var statsRows []statsRow
|
||||
if err := sqlx.SelectContext(ctx, dbReader, &statsRows, dbReader.Rebind(statsStmt), statsArgs...); err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching host report stats")
|
||||
}
|
||||
statsByQueryID := make(map[uint]*statsRow, len(statsRows))
|
||||
for i := range statsRows {
|
||||
statsByQueryID[statsRows[i].QueryID] = &statsRows[i]
|
||||
}
|
||||
|
||||
// Batch-fetch the most recent result row for each query on the page.
|
||||
type firstDataRow struct {
|
||||
QueryID uint `db:"query_id"`
|
||||
Data *json.RawMessage `db:"data"`
|
||||
}
|
||||
firstDataStmt, firstDataArgs, err := sqlx.In(`
|
||||
SELECT query_id, data
|
||||
FROM (
|
||||
SELECT
|
||||
query_id,
|
||||
data,
|
||||
ROW_NUMBER() OVER (PARTITION BY query_id ORDER BY last_fetched DESC) AS rn
|
||||
FROM query_results
|
||||
WHERE query_id IN (?) AND host_id = ? AND data IS NOT NULL
|
||||
) ranked
|
||||
WHERE rn = 1
|
||||
`, queryIDs, hostID)
|
||||
if err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "building first data query for host reports")
|
||||
}
|
||||
var firstDataRows []firstDataRow
|
||||
if err := sqlx.SelectContext(ctx, dbReader, &firstDataRows, dbReader.Rebind(firstDataStmt), firstDataArgs...); err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching first result data for host reports")
|
||||
}
|
||||
firstDataByQueryID := make(map[uint]*json.RawMessage, len(firstDataRows))
|
||||
for i := range firstDataRows {
|
||||
if firstDataRows[i].Data != nil {
|
||||
firstDataByQueryID[firstDataRows[i].QueryID] = firstDataRows[i].Data
|
||||
}
|
||||
}
|
||||
|
||||
// Map to HostReport structs, joining in the batch-fetched metadata.
|
||||
reports := make([]*fleet.HostReport, 0, len(queryRows))
|
||||
for _, qr := range queryRows {
|
||||
r := &fleet.HostReport{
|
||||
QueryID: qr.QueryID,
|
||||
Name: qr.Name,
|
||||
Description: qr.Description,
|
||||
StoreResults: !qr.DiscardData && qr.LoggingType == fleet.LoggingSnapshot,
|
||||
}
|
||||
if qr.LastResultFetched.Valid {
|
||||
t := qr.LastResultFetched.Time
|
||||
r.LastFetched = &t
|
||||
}
|
||||
if stats, ok := statsByQueryID[qr.QueryID]; ok {
|
||||
r.NHostResults = stats.NHostResults
|
||||
r.ReportClipped = stats.NQueryResults >= maxQueryReportRows
|
||||
}
|
||||
if data, ok := firstDataByQueryID[qr.QueryID]; ok {
|
||||
var cols map[string]string
|
||||
if err := json.Unmarshal(*data, &cols); err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "unmarshal first result data")
|
||||
}
|
||||
r.FirstResult = cols
|
||||
}
|
||||
reports = append(reports, r)
|
||||
}
|
||||
|
||||
return reports, total, metadata, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ func TestQueryResults(t *testing.T) {
|
|||
{"CleanupQueryResultRows", testCleanupQueryResultRows},
|
||||
{"CleanupExcessQueryResultRows", testCleanupExcessQueryResultRows},
|
||||
{"CleanupExcessQueryResultRowsManyQueries", testCleanupExcessQueryResultRowsManyQueries},
|
||||
{"ListHostReports", testListHostReports},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
|
@ -816,3 +817,330 @@ func testCleanupExcessQueryResultRowsManyQueries(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
require.Len(t, queryCounts, numQueries)
|
||||
}
|
||||
|
||||
func testListHostReports(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
user := test.NewUser(t, ds, "Test User", "list@example.com", true)
|
||||
host := test.NewHost(t, ds, "host1", "192.168.1.1", "key1", "serial1", time.Now())
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
earlier := now.Add(-time.Hour)
|
||||
|
||||
// Create queries: two that save results, one that discards them.
|
||||
qSave1 := test.NewQuery(t, ds, nil, "Save Query Alpha", "SELECT 1", user.ID, true)
|
||||
// Override DiscardData so it saves results (default is false, which is correct already).
|
||||
|
||||
_ = test.NewQuery(t, ds, nil, "Save Query Beta", "SELECT 2", user.ID, true)
|
||||
|
||||
// Create a query that discards results; excluded by default since it doesn't
|
||||
// satisfy discard_data=0 AND logging_type='snapshot'.
|
||||
qDiscard, err := ds.NewQuery(ctx, &fleet.Query{
|
||||
Name: "Discard Query Gamma",
|
||||
Query: "SELECT 3",
|
||||
AuthorID: &user.ID,
|
||||
Saved: true,
|
||||
DiscardData: true,
|
||||
Logging: fleet.LoggingDifferential,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a query with discard_data=false but logging_type='differential'.
|
||||
// This is the edge case fixed by the StoreResults check: even though
|
||||
// discard_data=0, it does not store snapshot reports, so StoreResults must
|
||||
// be false and it must be excluded by the default filter.
|
||||
qDifferentialNoDiscard, err := ds.NewQuery(ctx, &fleet.Query{
|
||||
Name: "Differential No Discard Delta",
|
||||
Query: "SELECT 4",
|
||||
AuthorID: &user.ID,
|
||||
Saved: true,
|
||||
DiscardData: false,
|
||||
Logging: fleet.LoggingDifferential,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert results for qSave1 on our host: two rows.
|
||||
rows1 := []*fleet.ScheduledQueryResultRow{
|
||||
{
|
||||
QueryID: qSave1.ID,
|
||||
HostID: host.ID,
|
||||
LastFetched: earlier,
|
||||
Data: ptr.RawMessage([]byte(`{"col":"row1"}`)),
|
||||
},
|
||||
{
|
||||
QueryID: qSave1.ID,
|
||||
HostID: host.ID,
|
||||
LastFetched: now,
|
||||
Data: ptr.RawMessage([]byte(`{"col":"row2"}`)),
|
||||
},
|
||||
}
|
||||
_, err = ds.OverwriteQueryResultRows(ctx, rows1, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
|
||||
// qSave2 has no results yet (should still be returned when SaveResults=true).
|
||||
|
||||
// Insert a result for qDiscard (to confirm it's excluded by default).
|
||||
rowsDiscard := []*fleet.ScheduledQueryResultRow{
|
||||
{
|
||||
QueryID: qDiscard.ID,
|
||||
HostID: host.ID,
|
||||
LastFetched: now,
|
||||
Data: ptr.RawMessage([]byte(`{"col":"discarded"}`)),
|
||||
},
|
||||
}
|
||||
_, err = ds.OverwriteQueryResultRows(ctx, rowsDiscard, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("default_excludes_dont_store_results_queries", func(t *testing.T) {
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
// IncludeReportsDontStoreResults defaults to false: only include discard_data=0 AND logging_type='snapshot'.
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name", IncludeMetadata: true},
|
||||
}
|
||||
reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
// Should get qSave1 and qSave2 but NOT qDiscard (doesn't satisfy discard_data=0 AND logging_type='snapshot').
|
||||
assert.Equal(t, 2, total)
|
||||
require.Len(t, reports, 2)
|
||||
assert.NotNil(t, meta)
|
||||
// Sorted by name ASC: "Save Query Alpha", "Save Query Beta".
|
||||
assert.Equal(t, "Save Query Alpha", reports[0].Name)
|
||||
assert.Equal(t, "Save Query Beta", reports[1].Name)
|
||||
})
|
||||
|
||||
t.Run("include_reports_dont_store_results_returns_all_queries", func(t *testing.T) {
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
IncludeReportsDontStoreResults: true,
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name", IncludeMetadata: true},
|
||||
}
|
||||
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
// All 4 queries are returned including both don't-store-results variants.
|
||||
assert.Equal(t, 4, total)
|
||||
require.Len(t, reports, 4)
|
||||
assert.Equal(t, "Differential No Discard Delta", reports[0].Name)
|
||||
assert.Equal(t, "Discard Query Gamma", reports[1].Name)
|
||||
assert.Equal(t, "Save Query Alpha", reports[2].Name)
|
||||
assert.Equal(t, "Save Query Beta", reports[3].Name)
|
||||
})
|
||||
|
||||
t.Run("store_results_field_reflects_both_discard_data_and_logging_type", func(t *testing.T) {
|
||||
// This subtest validates the fix: StoreResults must be true only when
|
||||
// discard_data=0 AND logging_type='snapshot'. A query with discard_data=0
|
||||
// but logging_type='differential' must have StoreResults=false.
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
IncludeReportsDontStoreResults: true,
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name"},
|
||||
}
|
||||
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reports, 4)
|
||||
|
||||
byName := make(map[string]*fleet.HostReport, len(reports))
|
||||
for _, r := range reports {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
|
||||
// discard_data=false, logging_type='snapshot' → StoreResults=true
|
||||
require.Contains(t, byName, qSave1.Name)
|
||||
assert.True(t, byName[qSave1.Name].StoreResults, "snapshot query should have StoreResults=true")
|
||||
|
||||
// discard_data=true, logging_type='differential' → StoreResults=false
|
||||
require.Contains(t, byName, qDiscard.Name)
|
||||
assert.False(t, byName[qDiscard.Name].StoreResults, "discard query should have StoreResults=false")
|
||||
|
||||
// discard_data=false, logging_type='differential' → StoreResults=false (the fixed edge case)
|
||||
require.Contains(t, byName, qDifferentialNoDiscard.Name)
|
||||
assert.False(t, byName[qDifferentialNoDiscard.Name].StoreResults, "differential query with discard_data=false should have StoreResults=false")
|
||||
})
|
||||
|
||||
t.Run("first_result_is_most_recent_non_null_row", func(t *testing.T) {
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name"},
|
||||
}
|
||||
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reports, 2)
|
||||
|
||||
// qSave1 has 2 results; first result should be the most recent one.
|
||||
alpha := reports[0]
|
||||
assert.Equal(t, "Save Query Alpha", alpha.Name)
|
||||
require.NotNil(t, alpha.FirstResult)
|
||||
assert.Equal(t, "row2", alpha.FirstResult["col"])
|
||||
// LastFetched should be MAX(last_fetched) = now.
|
||||
require.NotNil(t, alpha.LastFetched)
|
||||
assert.Equal(t, now.Unix(), alpha.LastFetched.Unix())
|
||||
assert.Equal(t, 2, alpha.NHostResults)
|
||||
})
|
||||
|
||||
t.Run("query_with_no_results_has_nil_first_result", func(t *testing.T) {
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name"},
|
||||
}
|
||||
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reports, 2)
|
||||
|
||||
// qSave2 has no results.
|
||||
beta := reports[1]
|
||||
assert.Equal(t, "Save Query Beta", beta.Name)
|
||||
assert.Nil(t, beta.FirstResult)
|
||||
assert.Nil(t, beta.LastFetched)
|
||||
assert.Equal(t, 0, beta.NHostResults)
|
||||
})
|
||||
|
||||
t.Run("name_filter", func(t *testing.T) {
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
OrderKey: "name",
|
||||
MatchQuery: "Alpha",
|
||||
},
|
||||
}
|
||||
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, total)
|
||||
require.Len(t, reports, 1)
|
||||
assert.Equal(t, "Save Query Alpha", reports[0].Name)
|
||||
})
|
||||
|
||||
t.Run("order_by_last_fetched_nulls_last", func(t *testing.T) {
|
||||
// qSave1 has results (non-null last_fetched), qSave2 has none (null).
|
||||
// NULLs should sort last regardless of ASC/DESC direction.
|
||||
|
||||
// ASC: non-null values first (oldest to newest), NULLs at bottom.
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
OrderKey: "last_fetched",
|
||||
OrderDirection: fleet.OrderAscending,
|
||||
},
|
||||
}
|
||||
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reports, 2)
|
||||
assert.Equal(t, "Save Query Alpha", reports[0].Name) // has results
|
||||
assert.Equal(t, "Save Query Beta", reports[1].Name) // no results → NULL last
|
||||
|
||||
// DESC: non-null values first (newest to oldest), NULLs at bottom.
|
||||
opts.ListOptions.OrderDirection = fleet.OrderDescending
|
||||
reports, _, _, err = ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reports, 2)
|
||||
assert.Equal(t, "Save Query Alpha", reports[0].Name) // has results
|
||||
assert.Equal(t, "Save Query Beta", reports[1].Name) // no results → NULL last
|
||||
})
|
||||
|
||||
t.Run("pagination", func(t *testing.T) {
|
||||
// Use PerPage:1 over the 2 save queries to exercise both pages.
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{
|
||||
OrderKey: "name",
|
||||
PerPage: 1,
|
||||
Page: 0,
|
||||
IncludeMetadata: true,
|
||||
},
|
||||
}
|
||||
reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, total)
|
||||
require.Len(t, reports, 1)
|
||||
require.NotNil(t, meta)
|
||||
assert.True(t, meta.HasNextResults)
|
||||
assert.False(t, meta.HasPreviousResults)
|
||||
|
||||
// Second page.
|
||||
opts.ListOptions.Page = 1
|
||||
reports2, total2, meta2, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, total2)
|
||||
require.Len(t, reports2, 1)
|
||||
require.NotNil(t, meta2)
|
||||
assert.False(t, meta2.HasNextResults)
|
||||
assert.True(t, meta2.HasPreviousResults)
|
||||
})
|
||||
|
||||
t.Run("team_scoping_excludes_other_team_queries", func(t *testing.T) {
|
||||
// Create a team first, then a query for that team.
|
||||
// Since host has no team, only global queries (team_id IS NULL) should be shown.
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "scoping-test-team"})
|
||||
require.NoError(t, err)
|
||||
_, err = ds.NewQuery(ctx, &fleet.Query{
|
||||
Name: "Team-Only Query",
|
||||
Query: "SELECT 4",
|
||||
AuthorID: &user.ID,
|
||||
Saved: true,
|
||||
TeamID: &team.ID,
|
||||
Logging: fleet.LoggingSnapshot,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name"},
|
||||
}
|
||||
reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
// Team-Only query should not appear because host has no team.
|
||||
assert.Equal(t, 2, total)
|
||||
for _, r := range reports {
|
||||
assert.NotEqual(t, "Team-Only Query", r.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("team_host_sees_global_and_team_queries", func(t *testing.T) {
|
||||
// Create a team, a team host, and a team-scoped query.
|
||||
// The host should see both global queries and its own team's queries.
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-host-test-team"})
|
||||
require.NoError(t, err)
|
||||
teamHost := test.NewHost(t, ds, "team-host1", "10.0.0.1", "key-team1", "serial-team1", time.Now())
|
||||
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{teamHost.ID}))
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = test.NewQuery(t, ds, &team.ID, "Team Query Zeta", "SELECT 5", user.ID, true)
|
||||
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name"},
|
||||
}
|
||||
reports, total, _, err := ds.ListHostReports(ctx, teamHost.ID, &team.ID, opts, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
// Should see the 2 global save queries plus the team-scoped query.
|
||||
assert.Equal(t, 3, total)
|
||||
names := make([]string, 0, len(reports))
|
||||
for _, r := range reports {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
assert.Contains(t, names, "Save Query Alpha")
|
||||
assert.Contains(t, names, "Save Query Beta")
|
||||
assert.Contains(t, names, "Team Query Zeta")
|
||||
})
|
||||
|
||||
t.Run("report_clipped_when_total_results_reach_cap", func(t *testing.T) {
|
||||
// Insert exactly maxQueryReportRows results for qSave1 across multiple hosts
|
||||
// so that n_query_results >= capacity, making report_clipped=true.
|
||||
capacity := 3
|
||||
extraHost := test.NewHost(t, ds, "extra-host", "192.168.2.1", "key2", "serial2", time.Now())
|
||||
t.Cleanup(func() {
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM query_results WHERE host_id = ?`, extraHost.ID)
|
||||
return err
|
||||
})
|
||||
})
|
||||
_, err := ds.OverwriteQueryResultRows(ctx, []*fleet.ScheduledQueryResultRow{
|
||||
{QueryID: qSave1.ID, HostID: extraHost.ID, LastFetched: now, Data: ptr.RawMessage([]byte(`{"col":"extra"}`))},
|
||||
}, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
// At this point qSave1 has 2 rows on host + 1 on extraHost = 3 total, which equals cap.
|
||||
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{OrderKey: "name"},
|
||||
}
|
||||
reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, capacity)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reports, 2)
|
||||
|
||||
alpha := reports[0]
|
||||
assert.Equal(t, "Save Query Alpha", alpha.Name)
|
||||
assert.True(t, alpha.ReportClipped)
|
||||
|
||||
// qSave2 has no results, so it should not be clipped.
|
||||
beta := reports[1]
|
||||
assert.Equal(t, "Save Query Beta", beta.Name)
|
||||
assert.False(t, beta.ReportClipped)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1367,6 +1367,16 @@ type ListQueryOptions struct {
|
|||
Platform *string
|
||||
}
|
||||
|
||||
// ListHostReportsOptions defines options for listing reports (queries) associated with a host.
|
||||
type ListHostReportsOptions struct {
|
||||
ListOptions
|
||||
// IncludeReportsDontStoreResults controls whether queries that don't store
|
||||
// results (discard_data=1 OR logging_type!='snapshot') are included.
|
||||
// false (default): only queries with discard_data=0 AND logging_type='snapshot' are returned.
|
||||
// true: all queries are returned, including ones that don't store results.
|
||||
IncludeReportsDontStoreResults bool
|
||||
}
|
||||
|
||||
// ApplySpecOptions are the options available when applying a YAML or JSON spec.
|
||||
type ApplySpecOptions struct {
|
||||
// Force indicates that any validation error in the incoming payload should
|
||||
|
|
|
|||
|
|
@ -578,6 +578,12 @@ type Datastore interface {
|
|||
// Deletes are batched to avoid large binlogs and long lock times. This runs as a cron job.
|
||||
// Returns a map of query IDs to their current row count after cleanup (for syncing Redis counters).
|
||||
CleanupExcessQueryResultRows(ctx context.Context, maxQueryReportRows int, opts ...CleanupExcessQueryResultRowsOptions) (map[uint]int, error)
|
||||
// ListHostReports returns the queries/reports associated with the given host, applying
|
||||
// the provided options for filtering, sorting, and pagination. teamID is the team of the
|
||||
// host (nil for global). maxQueryReportRows is the configured report cap used to determine
|
||||
// whether each query's report has been clipped. It returns the list of reports, the total
|
||||
// count (without pagination), optional pagination metadata, and any error.
|
||||
ListHostReports(ctx context.Context, hostID uint, teamID *uint, opts ListHostReportsOptions, maxQueryReportRows int) ([]*HostReport, int, *PaginationMetadata, error)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// TeamStore
|
||||
|
|
|
|||
|
|
@ -490,6 +490,31 @@ type HostQueryReportResult struct {
|
|||
Columns map[string]string `json:"columns"`
|
||||
}
|
||||
|
||||
// HostReport represents a query/report entry as returned by the list-reports-for-host endpoint.
|
||||
type HostReport struct {
|
||||
// QueryID is the unique identifier of the query.
|
||||
QueryID uint `json:"query_id" renameto:"report_id"`
|
||||
// Name is the name of the query.
|
||||
Name string `json:"name"`
|
||||
// Description is the description of the query.
|
||||
Description string `json:"description"`
|
||||
// LastFetched is the time the most recent result was received from the host.
|
||||
// It is nil if no results have been received.
|
||||
LastFetched *time.Time `json:"last_fetched"`
|
||||
// FirstResult contains the column key-value pairs of the most recent result row
|
||||
// for this host and query. It is nil if no results have been received.
|
||||
FirstResult map[string]string `json:"first_result"`
|
||||
// NHostResults is the number of non-null result rows stored for this host
|
||||
// and query.
|
||||
NHostResults int `json:"n_host_results"`
|
||||
// ReportClipped indicates whether this query has hit the report cap and
|
||||
// paused saving new results across all hosts.
|
||||
ReportClipped bool `json:"report_clipped"`
|
||||
// StoreResults indicates that the query is configured to store results.
|
||||
// It is true only when discard_data=0 AND logging_type='snapshot'.
|
||||
StoreResults bool `json:"store_results"`
|
||||
}
|
||||
|
||||
// ScheduledQueryResult holds results of a scheduled query received from a osquery agent.
|
||||
type ScheduledQueryResult struct {
|
||||
// QueryName is the name of the query.
|
||||
|
|
|
|||
|
|
@ -346,6 +346,10 @@ type Service interface {
|
|||
GetHostQueryReportResults(ctx context.Context, hid uint, queryID uint) (rows []HostQueryReportResult, lastFetched *time.Time, err error)
|
||||
// QueryReportIsClipped returns true if the number of query report rows exceeds the maximum
|
||||
QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error)
|
||||
// ListHostReports returns the reports/queries associated with the given host, filtered,
|
||||
// sorted, and paginated according to opts. The bool return value indicates whether
|
||||
// query reports are globally disabled in the org settings.
|
||||
ListHostReports(ctx context.Context, hostID uint, opts ListHostReportsOptions) (rows []*HostReport, total int, metadata *PaginationMetadata, savedReportsDisabled bool, err error)
|
||||
NewQuery(ctx context.Context, p QueryPayload) (*Query, error)
|
||||
ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error)
|
||||
DeleteQuery(ctx context.Context, teamID *uint, name string) error
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@ type CleanupDiscardedQueryResultsFunc func(ctx context.Context) error
|
|||
|
||||
type CleanupExcessQueryResultRowsFunc func(ctx context.Context, maxQueryReportRows int, opts ...fleet.CleanupExcessQueryResultRowsOptions) (map[uint]int, error)
|
||||
|
||||
type ListHostReportsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error)
|
||||
|
||||
type NewTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, error)
|
||||
|
||||
type SaveTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, error)
|
||||
|
|
@ -2460,6 +2462,9 @@ type DataStore struct {
|
|||
CleanupExcessQueryResultRowsFunc CleanupExcessQueryResultRowsFunc
|
||||
CleanupExcessQueryResultRowsFuncInvoked bool
|
||||
|
||||
ListHostReportsFunc ListHostReportsFunc
|
||||
ListHostReportsFuncInvoked bool
|
||||
|
||||
NewTeamFunc NewTeamFunc
|
||||
NewTeamFuncInvoked bool
|
||||
|
||||
|
|
@ -6012,6 +6017,13 @@ func (s *DataStore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryRe
|
|||
return s.CleanupExcessQueryResultRowsFunc(ctx, maxQueryReportRows, opts...)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListHostReports(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
|
||||
s.mu.Lock()
|
||||
s.ListHostReportsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListHostReportsFunc(ctx, hostID, teamID, opts, maxQueryReportRows)
|
||||
}
|
||||
|
||||
func (s *DataStore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
s.mu.Lock()
|
||||
s.NewTeamFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ type GetHostQueryReportResultsFunc func(ctx context.Context, hid uint, queryID u
|
|||
|
||||
type QueryReportIsClippedFunc func(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error)
|
||||
|
||||
type ListHostReportsFunc func(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, savedReportsDisabled bool, err error)
|
||||
|
||||
type NewQueryFunc func(ctx context.Context, p fleet.QueryPayload) (*fleet.Query, error)
|
||||
|
||||
type ModifyQueryFunc func(ctx context.Context, id uint, p fleet.QueryPayload) (*fleet.Query, error)
|
||||
|
|
@ -1152,6 +1154,9 @@ type Service struct {
|
|||
QueryReportIsClippedFunc QueryReportIsClippedFunc
|
||||
QueryReportIsClippedFuncInvoked bool
|
||||
|
||||
ListHostReportsFunc ListHostReportsFunc
|
||||
ListHostReportsFuncInvoked bool
|
||||
|
||||
NewQueryFunc NewQueryFunc
|
||||
NewQueryFuncInvoked bool
|
||||
|
||||
|
|
@ -2810,6 +2815,13 @@ func (s *Service) QueryReportIsClipped(ctx context.Context, queryID uint, maxQue
|
|||
return s.QueryReportIsClippedFunc(ctx, queryID, maxQueryReportRows)
|
||||
}
|
||||
|
||||
func (s *Service) ListHostReports(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, savedReportsDisabled bool, err error) {
|
||||
s.mu.Lock()
|
||||
s.ListHostReportsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListHostReportsFunc(ctx, hostID, opts)
|
||||
}
|
||||
|
||||
func (s *Service) NewQuery(ctx context.Context, p fleet.QueryPayload) (*fleet.Query, error) {
|
||||
s.mu.Lock()
|
||||
s.NewQueryFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -472,6 +472,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{})
|
||||
ue.GET("/api/_version_/fleet/os_versions/{id:[0-9]+}", getOSVersionEndpoint, getOSVersionRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/reports/{report_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{})
|
||||
ue.WithAltPaths("/api/_version_/fleet/hosts/{id:[0-9]+}/queries").GET("/api/_version_/fleet/hosts/{id:[0-9]+}/reports", listHostReportsEndpoint, listHostReportsRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{})
|
||||
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{})
|
||||
ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{})
|
||||
|
|
|
|||
|
|
@ -1942,6 +1942,128 @@ func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint,
|
|||
return result, lastFetched, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// List Host Reports
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type listHostReportsRequest struct {
|
||||
ID uint `url:"id"`
|
||||
ListOptions fleet.ListOptions `url:"list_options"`
|
||||
// IncludeReportsDontStoreResults if true, include reports that don't store results
|
||||
// (discard_data=1 AND logging_type != 'snapshot'). Defaults to false when omitted.
|
||||
IncludeReportsDontStoreResults *bool `query:"include_reports_dont_store_results,optional"`
|
||||
}
|
||||
|
||||
type listHostReportsFeatures struct {
|
||||
SavedReportsDisabled bool `json:"save_reports_disabled"`
|
||||
}
|
||||
|
||||
type listHostReportsResponse struct {
|
||||
Reports []*fleet.HostReport `json:"reports"`
|
||||
Count int `json:"count"`
|
||||
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
||||
Features listHostReportsFeatures `json:"features"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r listHostReportsResponse) Error() error { return r.Err }
|
||||
|
||||
func listHostReportsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*listHostReportsRequest)
|
||||
|
||||
var includeReportsDontStoreResults bool
|
||||
if req.IncludeReportsDontStoreResults != nil {
|
||||
includeReportsDontStoreResults = *req.IncludeReportsDontStoreResults
|
||||
}
|
||||
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
ListOptions: req.ListOptions,
|
||||
IncludeReportsDontStoreResults: includeReportsDontStoreResults,
|
||||
}
|
||||
|
||||
reports, count, meta, savedReportsDisabled, err := svc.ListHostReports(ctx, req.ID, opts)
|
||||
if err != nil {
|
||||
return listHostReportsResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return listHostReportsResponse{
|
||||
Reports: reports,
|
||||
Count: count,
|
||||
Meta: meta,
|
||||
Features: listHostReportsFeatures{
|
||||
SavedReportsDisabled: savedReportsDisabled,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) ListHostReports(
|
||||
ctx context.Context,
|
||||
hostID uint,
|
||||
opts fleet.ListHostReportsOptions,
|
||||
) (
|
||||
[]*fleet.HostReport,
|
||||
int,
|
||||
*fleet.PaginationMetadata,
|
||||
bool,
|
||||
error,
|
||||
) {
|
||||
// Load host to get team ID and authorize.
|
||||
host, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
setAuthCheckedOnPreAuthErr(ctx)
|
||||
return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "get host")
|
||||
}
|
||||
|
||||
// Verify the caller can read this specific host.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Host{ID: host.ID, TeamID: host.TeamID}, fleet.ActionRead); err != nil {
|
||||
return nil, 0, nil, false, err
|
||||
}
|
||||
|
||||
// Authorize against the host's team. Global queries (team_id IS NULL) are
|
||||
// intentionally visible to all users who can read queries in this context —
|
||||
// team-scoped users see global queries in addition to their own team's queries.
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Query{TeamID: host.TeamID}, fleet.ActionRead); err != nil {
|
||||
return nil, 0, nil, false, err
|
||||
}
|
||||
|
||||
appConfig, err := svc.AppConfigObfuscated(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "get app config")
|
||||
}
|
||||
maxQueryReportRows := appConfig.ServerSettings.GetQueryReportCap()
|
||||
savedReportsDisabled := appConfig.ServerSettings.QueryReportsDisabled
|
||||
|
||||
// This end-point is always paginated; metadata is required for HasNextResults.
|
||||
opts.ListOptions.IncludeMetadata = true
|
||||
// Default page size for this endpoint is 50 (not the global default).
|
||||
if opts.ListOptions.PerPage == 0 {
|
||||
opts.ListOptions.PerPage = 50
|
||||
}
|
||||
// Validate the order key before it reaches the datastore allowlist, so that
|
||||
// invalid values produce a clear 400 Bad Request instead of an internal error.
|
||||
switch opts.ListOptions.OrderKey {
|
||||
case "", "name", "last_fetched":
|
||||
// valid
|
||||
default:
|
||||
return nil, 0, nil, false, fleet.NewInvalidArgumentError("order_key", "must be one of: name, last_fetched")
|
||||
}
|
||||
|
||||
// Default: sort by newest results first. Applies only when the caller has
|
||||
// not specified an order key; explicit sorts (e.g. order_key=name) are
|
||||
// passed through unchanged.
|
||||
if opts.ListOptions.OrderKey == "" {
|
||||
opts.ListOptions.OrderKey = "last_fetched"
|
||||
opts.ListOptions.OrderDirection = fleet.OrderDescending
|
||||
}
|
||||
|
||||
reports, total, meta, err := svc.ds.ListHostReports(ctx, hostID, host.TeamID, opts, maxQueryReportRows)
|
||||
if err != nil {
|
||||
return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "list host reports from datastore")
|
||||
}
|
||||
|
||||
return reports, total, meta, savedReportsDisabled, nil
|
||||
}
|
||||
|
||||
func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, []*fleet.Host, error) {
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
|
|
|
|||
253
server/service/hosts_reports_test.go
Normal file
253
server/service/hosts_reports_test.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListHostReports(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
// global admin user
|
||||
admin := &fleet.User{
|
||||
ID: 1,
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}
|
||||
|
||||
// Host with no team
|
||||
hostNoTeam := &fleet.Host{ID: 10, TeamID: nil}
|
||||
// Host with team
|
||||
teamID := uint(42)
|
||||
hostWithTeam := &fleet.Host{ID: 20, TeamID: &teamID}
|
||||
|
||||
sampleReports := []*fleet.HostReport{
|
||||
{
|
||||
QueryID: 1,
|
||||
Name: "Query Alpha",
|
||||
Description: "desc alpha",
|
||||
LastFetched: &now,
|
||||
FirstResult: map[string]string{"col1": "val1"},
|
||||
NHostResults: 3,
|
||||
},
|
||||
{
|
||||
QueryID: 2,
|
||||
Name: "Query Beta",
|
||||
Description: "desc beta",
|
||||
LastFetched: nil,
|
||||
FirstResult: nil,
|
||||
NHostResults: 0,
|
||||
},
|
||||
}
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
|
||||
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
|
||||
if id == hostNoTeam.ID {
|
||||
return hostNoTeam, nil
|
||||
}
|
||||
if id == hostWithTeam.ID {
|
||||
return hostWithTeam, nil
|
||||
}
|
||||
return nil, errors.New("host not found")
|
||||
}
|
||||
|
||||
var capturedTeamID *uint
|
||||
ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
|
||||
capturedTeamID = tID
|
||||
return sampleReports, len(sampleReports), nil, nil
|
||||
}
|
||||
|
||||
svc, ctx := newTestService(t, ds, nil, nil)
|
||||
|
||||
t.Run("admin can list reports for host with no team", func(t *testing.T) {
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
|
||||
opts := fleet.ListHostReportsOptions{}
|
||||
reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Len(t, reports, 2)
|
||||
assert.Equal(t, "Query Alpha", reports[0].Name)
|
||||
assert.Equal(t, "Query Beta", reports[1].Name)
|
||||
assert.Equal(t, &now, reports[0].LastFetched)
|
||||
assert.Nil(t, reports[1].LastFetched)
|
||||
assert.Equal(t, 3, reports[0].NHostResults)
|
||||
assert.Equal(t, 0, reports[1].NHostResults)
|
||||
assert.Equal(t, map[string]string{"col1": "val1"}, reports[0].FirstResult)
|
||||
assert.Nil(t, reports[1].FirstResult)
|
||||
// host has no team, so nil teamID must be forwarded to the datastore.
|
||||
assert.Nil(t, capturedTeamID)
|
||||
})
|
||||
|
||||
t.Run("admin can list reports for host with team", func(t *testing.T) {
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
|
||||
opts := fleet.ListHostReportsOptions{}
|
||||
reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Len(t, reports, 2)
|
||||
// teamID must be forwarded to the datastore so it can scope queries correctly.
|
||||
assert.Equal(t, &teamID, capturedTeamID)
|
||||
})
|
||||
|
||||
t.Run("observer can list reports", func(t *testing.T) {
|
||||
observer := &fleet.User{
|
||||
ID: 2,
|
||||
GlobalRole: ptr.String(fleet.RoleObserver),
|
||||
}
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: observer})
|
||||
opts := fleet.ListHostReportsOptions{}
|
||||
reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Len(t, reports, 2)
|
||||
})
|
||||
|
||||
t.Run("save_reports_disabled is forwarded from app config", func(t *testing.T) {
|
||||
original := ds.AppConfigFunc
|
||||
t.Cleanup(func() { ds.AppConfigFunc = original })
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
ServerSettings: fleet.ServerSettings{QueryReportsDisabled: true},
|
||||
}, nil
|
||||
}
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
|
||||
_, _, _, disabled, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, disabled)
|
||||
})
|
||||
|
||||
t.Run("invalid order_key returns bad request", func(t *testing.T) {
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
|
||||
_, _, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{
|
||||
ListOptions: fleet.ListOptions{OrderKey: "invalid_key"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
var invalidArgErr *fleet.InvalidArgumentError
|
||||
require.ErrorAs(t, err, &invalidArgErr)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated gets error", func(t *testing.T) {
|
||||
_, _, _, _, err := svc.ListHostReports(ctx, hostNoTeam.ID, fleet.ListHostReportsOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "forbidden")
|
||||
})
|
||||
|
||||
t.Run("team observer cannot read host belonging to a different team", func(t *testing.T) {
|
||||
otherTeamID := uint(99)
|
||||
teamObserver := &fleet.User{
|
||||
ID: 3,
|
||||
Teams: []fleet.UserTeam{
|
||||
{Team: fleet.Team{ID: otherTeamID}, Role: fleet.RoleObserver},
|
||||
},
|
||||
}
|
||||
// hostWithTeam belongs to teamID=42; teamObserver only has access to teamID=99.
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: teamObserver})
|
||||
_, _, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, fleet.ListHostReportsOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "forbidden")
|
||||
})
|
||||
}
|
||||
|
||||
// TestListHostReportsDatastorePassthrough verifies the options are forwarded
|
||||
// to the datastore correctly.
|
||||
func TestListHostReportsDatastorePassthrough(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
teamID := uint(5)
|
||||
host := &fleet.Host{ID: 7, TeamID: &teamID}
|
||||
|
||||
admin := &fleet.User{
|
||||
ID: 1,
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
|
||||
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
|
||||
return host, nil
|
||||
}
|
||||
|
||||
capturedHostID := uint(0)
|
||||
capturedTeamID := (*uint)(nil)
|
||||
capturedOpts := fleet.ListHostReportsOptions{}
|
||||
|
||||
ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) {
|
||||
capturedHostID = hostID
|
||||
capturedTeamID = tID
|
||||
capturedOpts = opts
|
||||
return nil, 0, nil, nil
|
||||
}
|
||||
|
||||
svc, ctx := newTestService(t, ds, nil, nil)
|
||||
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
|
||||
|
||||
opts := fleet.ListHostReportsOptions{
|
||||
IncludeReportsDontStoreResults: true,
|
||||
ListOptions: fleet.ListOptions{
|
||||
Page: 1,
|
||||
PerPage: 10,
|
||||
OrderKey: "name",
|
||||
OrderDirection: fleet.OrderAscending,
|
||||
MatchQuery: "Alpha",
|
||||
},
|
||||
}
|
||||
|
||||
_, _, _, _, err := svc.ListHostReports(viewerCtx, host.ID, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, host.ID, capturedHostID)
|
||||
assert.Equal(t, &teamID, capturedTeamID)
|
||||
assert.True(t, capturedOpts.IncludeReportsDontStoreResults)
|
||||
assert.Equal(t, uint(1), capturedOpts.ListOptions.Page)
|
||||
assert.Equal(t, uint(10), capturedOpts.ListOptions.PerPage)
|
||||
assert.Equal(t, "name", capturedOpts.ListOptions.OrderKey)
|
||||
assert.Equal(t, "Alpha", capturedOpts.ListOptions.MatchQuery)
|
||||
}
|
||||
|
||||
// TestHostReportJSONRoundTrip verifies that HostReport serializes and
|
||||
// deserializes correctly, including the FirstResult and LastFetched fields.
|
||||
func TestHostReportJSONRoundTrip(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
// This test exercises the HostReport struct's FirstResult field to ensure
|
||||
// the data mapping from query_results.data JSON is correct.
|
||||
report := &fleet.HostReport{
|
||||
QueryID: 1,
|
||||
Name: "USB Devices",
|
||||
Description: "List USB devices",
|
||||
LastFetched: &now,
|
||||
FirstResult: map[string]string{"model": "USB Keyboard", "vendor": "Apple Inc."},
|
||||
NHostResults: 0,
|
||||
}
|
||||
|
||||
// Verify JSON serialization round-trip
|
||||
b, err := json.Marshal(report)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fleet.HostReport
|
||||
err = json.Unmarshal(b, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, report.QueryID, decoded.QueryID)
|
||||
assert.Equal(t, report.Name, decoded.Name)
|
||||
assert.Equal(t, report.Description, decoded.Description)
|
||||
assert.Equal(t, report.FirstResult, decoded.FirstResult)
|
||||
assert.Equal(t, report.NHostResults, decoded.NHostResults)
|
||||
assert.NotNil(t, decoded.LastFetched)
|
||||
}
|
||||
|
|
@ -16042,3 +16042,330 @@ func (s *integrationTestSuite) TestOsqueryBodySizeLimit() {
|
|||
ts.DoRawNoAuth("POST", "/api/osquery/distributed/write", withinLimitDist, http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestListHostReports() {
|
||||
t := s.T()
|
||||
ctx := t.Context()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
earlier := now.Add(-time.Hour)
|
||||
|
||||
// Create a global host (no team).
|
||||
host, err := s.ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String(t.Name()),
|
||||
NodeKey: ptr.String(t.Name()),
|
||||
UUID: uuid.New().String(),
|
||||
Hostname: t.Name() + ".local",
|
||||
Platform: "linux",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create two global queries that save results and one that discards them.
|
||||
admin := s.users[TestAdminUserEmail]
|
||||
qAlpha, err := s.ds.NewQuery(ctx, &fleet.Query{
|
||||
Name: t.Name() + "_alpha",
|
||||
Query: "SELECT 1",
|
||||
AuthorID: &admin.ID,
|
||||
Saved: true,
|
||||
DiscardData: false,
|
||||
Logging: fleet.LoggingSnapshot,
|
||||
Description: "alpha description",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
qBeta, err := s.ds.NewQuery(ctx, &fleet.Query{
|
||||
Name: t.Name() + "_beta",
|
||||
Query: "SELECT 2",
|
||||
AuthorID: &admin.ID,
|
||||
Saved: true,
|
||||
DiscardData: false,
|
||||
Logging: fleet.LoggingSnapshot,
|
||||
Description: "beta description",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
qDiscard, err := s.ds.NewQuery(ctx, &fleet.Query{
|
||||
Name: t.Name() + "_discard",
|
||||
Query: "SELECT 3",
|
||||
AuthorID: &admin.ID,
|
||||
Saved: true,
|
||||
DiscardData: true,
|
||||
Logging: fleet.LoggingDifferential, // non-snapshot + discard_data=1 → "don't store results"
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert two result rows for qAlpha on the host (to test has_more_results and first_result).
|
||||
_, err = s.ds.OverwriteQueryResultRows(ctx, []*fleet.ScheduledQueryResultRow{
|
||||
{QueryID: qAlpha.ID, HostID: host.ID, LastFetched: earlier, Data: ptr.RawMessage([]byte(`{"col":"older"}`))},
|
||||
{QueryID: qAlpha.ID, HostID: host.ID, LastFetched: now, Data: ptr.RawMessage([]byte(`{"col":"newest"}`))},
|
||||
}, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert one result row for qDiscard (only appears when include_reports_dont_store_results=true).
|
||||
_, err = s.ds.OverwriteQueryResultRows(ctx, []*fleet.ScheduledQueryResultRow{
|
||||
{QueryID: qDiscard.ID, HostID: host.ID, LastFetched: now, Data: ptr.RawMessage([]byte(`{"col":"discarded"}`))},
|
||||
}, fleet.DefaultMaxQueryReportRows)
|
||||
require.NoError(t, err)
|
||||
|
||||
url := fmt.Sprintf("/api/latest/fleet/hosts/%d/reports", host.ID)
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSONWithoutAuth("GET", url, nil, http.StatusUnauthorized, &resp)
|
||||
})
|
||||
|
||||
t.Run("observer can read reports", func(t *testing.T) {
|
||||
s.setTokenForTest(t, TestObserverUserEmail, test.GoodPassword)
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
})
|
||||
|
||||
t.Run("admin can read reports", func(t *testing.T) {
|
||||
s.setTokenForTest(t, TestAdminUserEmail, test.GoodPassword)
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
})
|
||||
|
||||
t.Run("nonexistent host returns 404", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/hosts/99999999/reports", nil, http.StatusNotFound, &resp)
|
||||
})
|
||||
|
||||
t.Run("default excludes dont-store-results queries", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name")
|
||||
require.NoError(t, resp.Err)
|
||||
assert.Equal(t, 2, resp.Count)
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
|
||||
for _, r := range resp.Reports {
|
||||
assert.NotEqual(t, qDiscard.Name, r.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include_reports_dont_store_results=true returns all queries", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "include_reports_dont_store_results", "true", "order_key", "name")
|
||||
require.NoError(t, resp.Err)
|
||||
assert.Equal(t, 3, resp.Count)
|
||||
require.Len(t, resp.Reports, 3)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
|
||||
assert.Equal(t, qDiscard.Name, resp.Reports[2].Name)
|
||||
})
|
||||
|
||||
t.Run("response fields are populated correctly", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name")
|
||||
require.NoError(t, resp.Err)
|
||||
require.Len(t, resp.Reports, 2)
|
||||
|
||||
alpha := resp.Reports[0]
|
||||
assert.Equal(t, qAlpha.ID, alpha.QueryID)
|
||||
assert.Equal(t, qAlpha.Name, alpha.Name)
|
||||
assert.Equal(t, "alpha description", alpha.Description)
|
||||
// first_result is the most recent row.
|
||||
require.NotNil(t, alpha.FirstResult)
|
||||
assert.Equal(t, "newest", alpha.FirstResult["col"])
|
||||
// last_fetched is the most recent timestamp.
|
||||
require.NotNil(t, alpha.LastFetched)
|
||||
assert.Equal(t, now.Unix(), alpha.LastFetched.Unix())
|
||||
// n_host_results is 2 because there are 2 rows.
|
||||
assert.Equal(t, 2, alpha.NHostResults)
|
||||
assert.False(t, alpha.ReportClipped)
|
||||
// qAlpha stores results (discard_data=false).
|
||||
assert.True(t, alpha.StoreResults)
|
||||
|
||||
// qBeta has no results yet.
|
||||
beta := resp.Reports[1]
|
||||
assert.Equal(t, qBeta.ID, beta.QueryID)
|
||||
assert.Nil(t, beta.FirstResult)
|
||||
assert.Nil(t, beta.LastFetched)
|
||||
assert.Equal(t, 0, beta.NHostResults)
|
||||
// qBeta also stores results.
|
||||
assert.True(t, beta.StoreResults)
|
||||
})
|
||||
|
||||
t.Run("store_results is false for discard queries", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "include_reports_dont_store_results", "true", "order_key", "name")
|
||||
require.NoError(t, resp.Err)
|
||||
require.Len(t, resp.Reports, 3)
|
||||
// qDiscard has discard_data=true → store_results must be false.
|
||||
discard := resp.Reports[2]
|
||||
assert.Equal(t, qDiscard.Name, discard.Name)
|
||||
assert.False(t, discard.StoreResults)
|
||||
})
|
||||
|
||||
t.Run("features.save_reports_disabled reflects app config", func(t *testing.T) {
|
||||
// Default: query reports are enabled.
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
|
||||
assert.False(t, resp.Features.SavedReportsDisabled)
|
||||
|
||||
// Save the current value before mutating.
|
||||
var originalConfig fleet.AppConfig
|
||||
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &originalConfig)
|
||||
origDisabled := originalConfig.ServerSettings.QueryReportsDisabled
|
||||
|
||||
// Disable query reports.
|
||||
s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{"server_settings":{"query_reports_disabled":true}}`), http.StatusOK)
|
||||
t.Cleanup(func() {
|
||||
s.DoRaw("PATCH", "/api/latest/fleet/config",
|
||||
fmt.Appendf(nil, `{"server_settings":{"query_reports_disabled":%v}}`, origDisabled),
|
||||
http.StatusOK)
|
||||
})
|
||||
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
|
||||
assert.True(t, resp.Features.SavedReportsDisabled)
|
||||
})
|
||||
|
||||
t.Run("report_clipped when total results reach the cap", func(t *testing.T) {
|
||||
// Save the current cap before mutating.
|
||||
var originalConfig fleet.AppConfig
|
||||
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &originalConfig)
|
||||
origCap := originalConfig.ServerSettings.QueryReportCap
|
||||
|
||||
// Set the report cap to 2, which equals the number of rows already stored for qAlpha.
|
||||
s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{"server_settings":{"query_report_cap":2}}`), http.StatusOK)
|
||||
t.Cleanup(func() {
|
||||
s.DoRaw("PATCH", "/api/latest/fleet/config",
|
||||
fmt.Appendf(nil, `{"server_settings":{"query_report_cap":%d}}`, origCap),
|
||||
http.StatusOK)
|
||||
})
|
||||
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name")
|
||||
require.NoError(t, resp.Err)
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.True(t, resp.Reports[0].ReportClipped) // qAlpha has 2 rows == cap of 2
|
||||
assert.False(t, resp.Reports[1].ReportClipped) // qBeta has 0 rows
|
||||
})
|
||||
|
||||
t.Run("name search", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "query", "alpha")
|
||||
require.NoError(t, resp.Err)
|
||||
assert.Equal(t, 1, resp.Count)
|
||||
require.Len(t, resp.Reports, 1)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
})
|
||||
|
||||
t.Run("sort by name ascending and descending", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
|
||||
// Ascending (A→Z).
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name", "order_direction", "asc")
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
|
||||
|
||||
// Descending (Z→A).
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name", "order_direction", "desc")
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[1].Name)
|
||||
})
|
||||
|
||||
t.Run("sort by last_fetched puts nulls last", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
|
||||
// ASC: qAlpha (has results) before qBeta (no results / NULL).
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "last_fetched", "order_direction", "asc")
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
|
||||
|
||||
// DESC: same NULL-last behaviour.
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "last_fetched", "order_direction", "desc")
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
|
||||
})
|
||||
|
||||
t.Run("pagination", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
|
||||
// Page 0, 1 item per page.
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp,
|
||||
"order_key", "name", "per_page", "1", "page", "0")
|
||||
require.NoError(t, resp.Err)
|
||||
assert.Equal(t, 2, resp.Count)
|
||||
require.Len(t, resp.Reports, 1)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
require.NotNil(t, resp.Meta)
|
||||
assert.True(t, resp.Meta.HasNextResults)
|
||||
assert.False(t, resp.Meta.HasPreviousResults)
|
||||
|
||||
// Page 1, 1 item per page.
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp,
|
||||
"order_key", "name", "per_page", "1", "page", "1")
|
||||
require.NoError(t, resp.Err)
|
||||
assert.Equal(t, 2, resp.Count)
|
||||
require.Len(t, resp.Reports, 1)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[0].Name)
|
||||
require.NotNil(t, resp.Meta)
|
||||
assert.False(t, resp.Meta.HasNextResults)
|
||||
assert.True(t, resp.Meta.HasPreviousResults)
|
||||
})
|
||||
|
||||
t.Run("default page size is 50", func(t *testing.T) {
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
require.NotNil(t, resp.Meta)
|
||||
// With only 2 queries, both fit within the default page size of 50.
|
||||
assert.False(t, resp.Meta.HasNextResults)
|
||||
assert.Len(t, resp.Reports, 2)
|
||||
})
|
||||
|
||||
t.Run("alt path /hosts/:id/queries works", func(t *testing.T) {
|
||||
altURL := fmt.Sprintf("/api/latest/fleet/hosts/%d/queries", host.ID)
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", altURL, nil, http.StatusOK, &resp, "order_key", "name")
|
||||
require.NoError(t, resp.Err)
|
||||
assert.Equal(t, 2, resp.Count)
|
||||
})
|
||||
|
||||
t.Run("default sort is last_fetched descending (newest first)", func(t *testing.T) {
|
||||
// With no order params, the endpoint defaults to last_fetched DESC.
|
||||
// qAlpha has results (non-NULL last_fetched) so it comes first; qBeta
|
||||
// has no results (NULL) so it sorts last.
|
||||
var resp listHostReportsResponse
|
||||
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
require.Len(t, resp.Reports, 2)
|
||||
assert.Equal(t, qAlpha.Name, resp.Reports[0].Name)
|
||||
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
|
||||
})
|
||||
|
||||
t.Run("report_id alias is present in JSON response", func(t *testing.T) {
|
||||
// The HostReport struct uses `renameto:"report_id"` on the QueryID field,
|
||||
// which causes the endpointer to duplicate the key in the response as
|
||||
// both "query_id" (deprecated) and "report_id" (new name).
|
||||
rawBody := s.DoRaw("GET", url, nil, http.StatusOK)
|
||||
var raw map[string]any
|
||||
require.NoError(t, json.NewDecoder(rawBody.Body).Decode(&raw))
|
||||
|
||||
reports, ok := raw["reports"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, reports)
|
||||
|
||||
firstReport, ok := reports[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
// Both the deprecated key and the new alias must be present.
|
||||
_, hasQueryID := firstReport["query_id"]
|
||||
_, hasReportID := firstReport["report_id"]
|
||||
assert.True(t, hasQueryID, "expected deprecated key 'query_id' in response")
|
||||
assert.True(t, hasReportID, "expected alias key 'report_id' in response")
|
||||
assert.Equal(t, firstReport["query_id"], firstReport["report_id"])
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue