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:
Juan Fernandez 2026-03-18 11:03:48 -04:00 committed by GitHub
parent 701b4a7247
commit b226eb56d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1327 additions and 0 deletions

View 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.

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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{})

View file

@ -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 {

View 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)
}

View file

@ -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"])
})
}