Files
fleet/server/service/pack_config_cache_test.go
Carlo 5e2b76a3ad Rename setup_experience_platforms to singular comma-separated setup_experience_platform (#49245)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #43667

# Summary

Renames the unreleased GitOps field `setup_experience_platforms` to
singular `setup_experience_platform`, accepting a comma-separated string
of `darwin`/`linux` (rejecting the `macos` alias) to match the
query/policy/label `platform` convention.

# Checklist for submitter

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [ ] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually

## New Fleet configuration settings

- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)


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

## Summary by CodeRabbit

* **Improvements**
* Updated software setup-experience platform configuration to use a
single comma-separated `setup_experience_platform` value.
* Platform values are normalized for casing and whitespace,
deduplicated, and validated against supported platforms.
* macOS setup selections now use the canonical `darwin` value; the
`macos` alias is rejected.
* GitOps-generated configurations now use the updated field name and
platform format.
* **Bug Fixes**
* Improved validation messages for invalid setup-experience platform
values.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-13 20:02:36 -04:00

332 lines
11 KiB
Go

package service
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
gocache "github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func rawMessagePtr(s string) *json.RawMessage {
raw := json.RawMessage(s)
return &raw
}
// setupPackConfigCacheTest creates a mock datastore and service configured for
// pack config cache testing. The returned callCounter tracks the number of
// times ListScheduledQueriesForAgents is invoked (the main DB call that the
// cache is intended to avoid).
func setupPackConfigCacheTest(t *testing.T) (
svc *Service,
ds *mock.Store,
callCounter *atomic.Int64,
) {
t.Helper()
ds = new(mock.Store)
callCounter = &atomic.Int64{}
// Base agent options (minimal).
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
AgentOptions: rawMessagePtr(`{"config":{"options":{"pack_delimiter":"/"}}}`),
}, nil
}
ds.TeamAgentOptionsFunc = func(ctx context.Context, teamID uint) (*json.RawMessage, error) {
return nil, nil
}
// No legacy packs by default.
ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Pack, error) {
return []*fleet.Pack{}, nil
}
// Default: no scheduled queries in packs.
ds.ListScheduledQueriesInPackFunc = func(ctx context.Context, packID uint) (fleet.ScheduledQueryList, error) {
return []*fleet.ScheduledQuery{}, nil
}
// Scheduled queries for agents -- this is the main DB call we track.
ds.ListScheduledQueriesForAgentsFunc = func(ctx context.Context, teamID *uint, hostID *uint, queryReportsDisabled bool) ([]*fleet.Query, error) {
callCounter.Add(1)
if teamID == nil {
return []*fleet.Query{
{
Name: "global_query",
Query: "SELECT 1",
Interval: 60,
Logging: "snapshot",
},
}, nil
}
return []*fleet.Query{
{
Name: "team_query",
Query: "SELECT 2",
Interval: 30,
Logging: "differential",
TeamID: teamID,
},
}, nil
}
ds.UpdateHostFunc = func(ctx context.Context, host *fleet.Host) error {
return nil
}
ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return &fleet.Host{ID: id}, nil
}
fleetSvc, _ := newTestService(t, ds, nil, nil)
svc = fleetSvc.(validationMiddleware).Service.(*Service)
return svc, ds, callCounter
}
// TestPackConfigCacheHit verifies that two consecutive GetClientConfig calls
// for the same host return the same pack config and that the second call does
// not hit the DB for scheduled queries.
func TestPackConfigCacheHit(t *testing.T) {
svc, _, callCounter := setupPackConfigCacheTest(t)
host := &fleet.Host{ID: 1}
ctx := hostctx.NewContext(t.Context(), host)
// First call -- cache miss, should hit DB.
conf1, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
require.Contains(t, conf1, "packs")
callsBefore := callCounter.Load()
require.Positive(t, callsBefore, "expected at least one DB call on cache miss")
// Second call -- cache hit, should NOT call ListScheduledQueriesForAgents again.
conf2, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
callsAfter := callCounter.Load()
assert.Equal(t, callsBefore, callsAfter, "expected no additional DB calls on cache hit")
// Verify the pack config content is identical.
assert.JSONEq(t,
string(conf1["packs"].(json.RawMessage)),
string(conf2["packs"].(json.RawMessage)),
)
}
// TestPackConfigCacheNegativeCache verifies that when no scheduled queries
// exist for a team, the empty result is cached (negative cache) so subsequent
// requests don't hit the DB.
func TestPackConfigCacheNegativeCache(t *testing.T) {
svc, ds, callCounter := setupPackConfigCacheTest(t)
// Override: no scheduled queries at all.
ds.ListScheduledQueriesForAgentsFunc = func(ctx context.Context, teamID *uint, hostID *uint, queryReportsDisabled bool) ([]*fleet.Query, error) {
callCounter.Add(1)
return nil, nil
}
host := &fleet.Host{ID: 1}
ctx := hostctx.NewContext(t.Context(), host)
// First call -- cache miss, hits DB, finds no queries.
conf1, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
_, hasPacks := conf1["packs"]
assert.False(t, hasPacks, "expected no packs when no queries are configured")
callsAfterFirst := callCounter.Load()
require.Positive(t, callsAfterFirst, "expected at least one DB call on first request")
// Second call -- should be a cache hit (negative cache), no additional DB calls.
conf2, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
_, hasPacks = conf2["packs"]
assert.False(t, hasPacks, "expected no packs on cached empty result")
assert.Equal(t, callsAfterFirst, callCounter.Load(),
"expected no additional DB calls -- empty result should be cached")
}
// TestPackConfigCacheTTLExpiration verifies that after the cache TTL expires,
// a fresh config is built from the DB. This is the primary mechanism for
// picking up query changes (no explicit invalidation).
func TestPackConfigCacheTTLExpiration(t *testing.T) {
svc, ds, callCounter := setupPackConfigCacheTest(t)
// Replace the cache with a very short TTL so the test doesn't wait long.
svc.packConfigCache = gocache.New(50*time.Millisecond, 25*time.Millisecond)
host := &fleet.Host{ID: 1}
ctx := hostctx.NewContext(t.Context(), host)
// Warm the cache.
_, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
callsAfterWarm := callCounter.Load()
// Confirm cache hit.
_, err = svc.GetClientConfig(ctx)
require.NoError(t, err)
assert.Equal(t, callsAfterWarm, callCounter.Load(), "expected cache hit before TTL expiry")
// Wait for TTL to expire.
time.Sleep(100 * time.Millisecond)
// Update the mock so we can detect a fresh DB read.
ds.ListScheduledQueriesForAgentsFunc = func(ctx context.Context, teamID *uint, hostID *uint, queryReportsDisabled bool) ([]*fleet.Query, error) {
callCounter.Add(1)
if teamID == nil {
return []*fleet.Query{
{Name: "refreshed_query", Query: "SELECT 'refreshed'", Interval: 60, Logging: "snapshot"},
}, nil
}
return nil, nil
}
conf, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
assert.Greater(t, callCounter.Load(), callsAfterWarm, "expected DB call after TTL expiry")
assert.Contains(t, string(conf["packs"].(json.RawMessage)), "refreshed_query")
}
// TestPackConfigCacheQueryChangesPickedUpAfterTTL verifies that when queries
// are created, modified, or deleted, the changes are picked up after the cache
// TTL expires (no explicit invalidation needed).
func TestPackConfigCacheQueryChangesPickedUpAfterTTL(t *testing.T) {
svc, ds, callCounter := setupPackConfigCacheTest(t)
svc.packConfigCache = gocache.New(50*time.Millisecond, 25*time.Millisecond)
host := &fleet.Host{ID: 1}
ctx := hostctx.NewContext(t.Context(), host)
// Warm the cache with the original query.
conf1, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
assert.Contains(t, string(conf1["packs"].(json.RawMessage)), "global_query")
// Simulate a query being created + modified.
ds.ListScheduledQueriesForAgentsFunc = func(ctx context.Context, teamID *uint, hostID *uint, queryReportsDisabled bool) ([]*fleet.Query, error) {
callCounter.Add(1)
if teamID == nil {
return []*fleet.Query{
{Name: "global_query", Query: "SELECT 'modified'", Interval: 60, Logging: "snapshot"},
{Name: "new_query", Query: "SELECT 'new'", Interval: 120, Logging: "snapshot"},
}, nil
}
return nil, nil
}
// Still within TTL -- should serve stale cache.
conf2, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
assert.Contains(t, string(conf2["packs"].(json.RawMessage)), "SELECT 1")
assert.NotContains(t, string(conf2["packs"].(json.RawMessage)), "new_query")
// Wait for TTL to expire.
time.Sleep(100 * time.Millisecond)
// Now the changes should be picked up.
conf3, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
packJSON := string(conf3["packs"].(json.RawMessage))
assert.Contains(t, packJSON, "SELECT 'modified'")
assert.Contains(t, packJSON, "new_query")
}
// TestPackConfigCacheTeamIsolation verifies that hosts in different teams get
// different cached configs and that caching one team's config does not affect
// another team's config.
func TestPackConfigCacheTeamIsolation(t *testing.T) {
svc, _, callCounter := setupPackConfigCacheTest(t)
globalHost := &fleet.Host{ID: 1}
team1Host := &fleet.Host{ID: 2, TeamID: new(uint(1))}
team2Host := &fleet.Host{ID: 3, TeamID: new(uint(2))}
ctxGlobal := hostctx.NewContext(t.Context(), globalHost)
ctxTeam1 := hostctx.NewContext(t.Context(), team1Host)
ctxTeam2 := hostctx.NewContext(t.Context(), team2Host)
// Fetch config for each.
confGlobal, err := svc.GetClientConfig(ctxGlobal)
require.NoError(t, err)
confTeam1, err := svc.GetClientConfig(ctxTeam1)
require.NoError(t, err)
confTeam2, err := svc.GetClientConfig(ctxTeam2)
require.NoError(t, err)
callsAfterAllFetched := callCounter.Load()
// Global config should have "Global" pack but no team pack.
globalPacks := string(confGlobal["packs"].(json.RawMessage))
assert.Contains(t, globalPacks, `"Global"`)
assert.NotContains(t, globalPacks, `"team-1"`)
assert.NotContains(t, globalPacks, `"team-2"`)
// Team 1 should have both "Global" and "team-1" packs.
team1Packs := string(confTeam1["packs"].(json.RawMessage))
assert.Contains(t, team1Packs, `"Global"`)
assert.Contains(t, team1Packs, `"team-1"`)
assert.NotContains(t, team1Packs, `"team-2"`)
// Team 2 should have both "Global" and "team-2" packs.
team2Packs := string(confTeam2["packs"].(json.RawMessage))
assert.Contains(t, team2Packs, `"Global"`)
assert.Contains(t, team2Packs, `"team-2"`)
assert.NotContains(t, team2Packs, `"team-1"`)
// Now fetch all three again -- all should be cache hits.
_, err = svc.GetClientConfig(ctxGlobal)
require.NoError(t, err)
_, err = svc.GetClientConfig(ctxTeam1)
require.NoError(t, err)
_, err = svc.GetClientConfig(ctxTeam2)
require.NoError(t, err)
assert.Equal(t, callsAfterAllFetched, callCounter.Load(),
"expected no additional DB calls -- all three team configs should be cached independently")
}
// TestPackConfigCacheLegacyPacksBypass verifies that when a host has legacy
// packs assigned, the cache is bypassed entirely (every call hits the DB).
func TestPackConfigCacheLegacyPacksBypass(t *testing.T) {
svc, ds, callCounter := setupPackConfigCacheTest(t)
// Assign a legacy pack to host 1.
ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Pack, error) {
if hid == 1 {
return []*fleet.Pack{{ID: 10, Name: "legacy_pack"}}, nil
}
return []*fleet.Pack{}, nil
}
ds.ListScheduledQueriesInPackFunc = func(ctx context.Context, packID uint) (fleet.ScheduledQueryList, error) {
if packID == 10 {
return []*fleet.ScheduledQuery{
{Name: "legacy_q", Query: "SELECT 'legacy'", Interval: 30},
}, nil
}
return []*fleet.ScheduledQuery{}, nil
}
legacyHost := &fleet.Host{ID: 1}
ctx := hostctx.NewContext(t.Context(), legacyHost)
// First call.
conf1, err := svc.GetClientConfig(ctx)
require.NoError(t, err)
callsAfterFirst := callCounter.Load()
assert.Contains(t, string(conf1["packs"].(json.RawMessage)), "legacy_pack")
// Second call -- should still hit DB because legacy packs bypass cache.
_, err = svc.GetClientConfig(ctx)
require.NoError(t, err)
assert.Greater(t, callCounter.Load(), callsAfterFirst,
"expected DB call even on second request when legacy packs are present")
}