Files
fleet/cmd/fleet-mcp/fleet_integration_test.go
Lucas Manuel Rodriguez 56763d13c1 Move fleet-mcp from tools/ to cmd/ (#49044)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #43544. 

Moves `fleet-mcp` from `tools/fleet-mcp/` to `cmd/fleet-mcp/`. It is
becoming a production server used by customers, so it now lives under
`cmd/` alongside the other Fleet binaries.

Per the module strategy chosen for this move, it **remains a standalone
Go module** (keeps its own `go.mod`/`go.sum` and isolated deps such as
`mark3labs/mcp-go`, `logrus`, `gorilla/websocket`, `godotenv`) — the
root `github.com/fleetdm/fleet/v4` module is unchanged.

### What changed
- `git mv tools/fleet-mcp/ → cmd/fleet-mcp/` (history preserved as
renames).
- Updated all path references:
  - Root `Makefile` `update-go` module list.
- `.github/workflows/test-fleet-mcp.yml` — trigger paths,
`go-version-file`, `working-directory`.
  - `.github/dependabot.yml` — gomod directory.
  - `cmd/fleet-mcp/render.yaml` — `rootDir`.
- `cmd/fleet-mcp/README.md`, `Makefile`, `schema.go` — path
comments/links.
  - `articles/fleet-mcp.md` — README link.
  - Removed the `fleet-mcp/` row from `tools/README.md`.

### Follow-up (not in this PR)
- The Render service's Blueprint file path must be updated from
`tools/fleet-mcp/render.yaml` to `cmd/fleet-mcp/render.yaml` in the
Render dashboard.

## Testing
- `go build .` in `cmd/fleet-mcp` — OK
- `go test -race -count=1 ./...` — `ok fleet-mcp`

- [x] QA'd all new/changed functionality manually
2026-07-09 13:29:09 -03:00

1106 lines
36 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
)
func newTestClient(serverURL string) *FleetClient {
return &FleetClient{
baseURL: serverURL,
apiKey: "test",
httpClient: http.DefaultClient,
}
}
func TestEndpointMatchesHostname(t *testing.T) {
cases := []struct {
name string
ep Endpoint
in string
want bool
}{
{
name: "matches Name exactly",
ep: Endpoint{Name: "alpha.local"},
in: "alpha.local",
want: true,
},
{
name: "matches ComputerName case-insensitively",
ep: Endpoint{ComputerName: "MyMac"},
in: "mymac",
want: true,
},
{
name: "matches DisplayName",
ep: Endpoint{DisplayName: "USS Protostar"},
in: "USS Protostar",
want: true,
},
{
name: "no match — substring on serial only",
ep: Endpoint{Name: "host123.local", HardwareSerial: "trex-serial"},
in: "trex",
want: false,
},
{
name: "no match — substring on IP only",
ep: Endpoint{Name: "host.local", PrimaryIP: "192.168.1.42"},
in: "192.168",
want: false,
},
{
name: "different hostname does not match",
ep: Endpoint{Name: "alpha.local", ComputerName: "alpha", DisplayName: "Alpha"},
in: "beta.local",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := endpointMatchesHostname(tc.ep, tc.in); got != tc.want {
t.Errorf("endpointMatchesHostname(%+v, %q) = %v, want %v", tc.ep, tc.in, got, tc.want)
}
})
}
}
func TestFetchHostsFromPathBounded_PaginatesUntilShortPage(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
page := r.URL.Query().Get("page")
var n int
switch page {
case "0":
n = 500
case "1":
n = 200
default:
t.Errorf("unexpected page %q", page)
http.Error(w, "unexpected page", http.StatusBadRequest)
return
}
hosts := make([]Endpoint, n)
for i := range hosts {
hosts[i] = Endpoint{ID: uint(i + 1)}
}
_ = json.NewEncoder(w).Encode(struct {
Hosts []Endpoint `json:"hosts"`
}{Hosts: hosts})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
out, truncated, err := fc.fetchHostsFromPathBounded(context.Background(), "/api/v1/fleet/hosts", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if truncated {
t.Errorf("expected truncated=false")
}
if got, want := len(out), 700; got != want {
t.Errorf("len(out) = %d, want %d", got, want)
}
if got := calls.Load(); got != 2 {
t.Errorf("expected 2 page calls, got %d", got)
}
}
func TestFetchHostsFromPathBounded_HardCapTruncates(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
hosts := make([]Endpoint, 500)
for i := range hosts {
hosts[i] = Endpoint{ID: uint(n)*1000 + uint(i+1)}
}
_ = json.NewEncoder(w).Encode(struct {
Hosts []Endpoint `json:"hosts"`
}{Hosts: hosts})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
out, truncated, err := fc.fetchHostsFromPathBounded(context.Background(), "/api/v1/fleet/hosts", 600)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !truncated {
t.Errorf("expected truncated=true")
}
if got, want := len(out), 600; got != want {
t.Errorf("len(out) = %d, want %d (cap)", got, want)
}
if got := calls.Load(); got != 2 {
t.Errorf("expected 2 page calls before cap kicks in, got %d", got)
}
}
func TestGetVulnerabilityImpact_PropagatesTruncated(t *testing.T) {
// Lower the cap so a small mock host set trips truncation.
orig := fetchHostsHardCap
fetchHostsHardCap = 5
t.Cleanup(func() { fetchHostsHardCap = orig })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/fleet/hosts":
// Step 3: return more hosts than the cap (set to 5 above) so the
// page-truncate branch fires and sets truncated=true.
hosts := make([]Endpoint, 10)
for i := range hosts {
hosts[i] = Endpoint{ID: uint(i + 1)}
}
_ = json.NewEncoder(w).Encode(struct {
Hosts []Endpoint `json:"hosts"`
}{Hosts: hosts})
case strings.HasPrefix(r.URL.Path, "/api/v1/fleet/software/titles/"):
// Step 2: one version per title.
_ = json.NewEncoder(w).Encode(map[string]any{
"software_title": map[string]any{
"versions": []map[string]any{{"id": 99}},
},
})
case r.URL.Path == "/api/v1/fleet/software/titles":
// Step 1: one title, short page → stop.
_ = json.NewEncoder(w).Encode(map[string]any{
"software_titles": []map[string]any{{"id": 1}},
})
case r.URL.Path == "/api/v1/fleet/hosts/count":
_ = json.NewEncoder(w).Encode(map[string]any{"count": 1000})
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
}
}))
defer srv.Close()
fc := newTestClient(srv.URL)
impact, err := fc.GetVulnerabilityImpact(context.Background(), "CVE-2026-12345")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !impact.Truncated {
t.Errorf("expected Truncated=true to propagate from per-version-id fetch")
}
if impact.ImpactedSystems == 0 {
t.Errorf("expected ImpactedSystems > 0, got %d", impact.ImpactedSystems)
}
}
func TestBearerAuthMiddleware(t *testing.T) {
const token = "secret-token"
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
h := bearerAuthMiddleware(token, next)
cases := []struct {
name string
header string
wantStatus int
wantCalled bool
}{
{"missing header", "", http.StatusUnauthorized, false},
{"wrong scheme", "Basic " + token, http.StatusUnauthorized, false},
{"wrong token", "Bearer wrong", http.StatusUnauthorized, false},
{"correct token", "Bearer " + token, http.StatusOK, true},
{"trailing junk", "Bearer " + token + "x", http.StatusUnauthorized, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
called = false
req := httptest.NewRequest("GET", "/", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if called != tc.wantCalled {
t.Errorf("next called = %v, want %v", called, tc.wantCalled)
}
})
}
}
func TestValidateCVEID(t *testing.T) {
cases := []struct {
in string
wantErr bool
}{
{"CVE-2026-12345", false},
{"CVE-1999-0001", false}, // 4-digit minimum
{" CVE-2026-12345 ", false}, // trims
{"", true},
{" ", true},
{"cve-2026-12345", true}, // case-sensitive
{"CVE-26-12345", true}, // year too short
{"CVE-2026-123", true}, // suffix too short
{"CVE-2026-12345x", true}, // trailing junk
{"CVE-2026", true}, // missing suffix
{"<script>", true}, // injection-shaped junk
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
err := validateCVEID(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("validateCVEID(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr)
}
})
}
}
func TestParsePositiveUintString(t *testing.T) {
cases := []struct {
in string
wantN uint64
wantErr bool
}{
{"1", 1, false},
{"42", 42, false},
{" 42 ", 42, false},
{"0", 0, true},
{"", 0, true},
{" ", 0, true},
{"-1", 0, true},
{"abc", 0, true},
{"1.5", 0, true},
{"1e2", 0, true},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
n, err := parsePositiveUintString("policy_id", tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("err=%v, wantErr=%v", err, tc.wantErr)
}
if n != tc.wantN {
t.Errorf("n=%d, want %d", n, tc.wantN)
}
})
}
}
func TestGetHostsForCVE_PaginatesTitles(t *testing.T) {
var titlesCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/v1/fleet/software/titles/"):
_ = json.NewEncoder(w).Encode(map[string]any{
"software_title": map[string]any{"versions": []any{}},
})
case r.URL.Path == "/api/v1/fleet/software/titles":
titlesCalls.Add(1)
page := r.URL.Query().Get("page")
n, _ := strconv.Atoi(page)
var count int
switch n {
case 0:
count = 100
case 1:
count = 30
default:
t.Errorf("unexpected titles page %d", n)
http.Error(w, "unexpected page", http.StatusBadRequest)
return
}
type title struct {
ID uint `json:"id"`
}
titles := make([]title, count)
for i := range titles {
titles[i].ID = uint(n*1000 + i + 1)
}
_ = json.NewEncoder(w).Encode(struct {
SoftwareTitles []title `json:"software_titles"`
}{SoftwareTitles: titles})
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
}
}))
defer srv.Close()
fc := newTestClient(srv.URL)
hosts, truncated, err := fc.GetHostsForCVE(context.Background(), "CVE-2026-12345", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if truncated {
t.Errorf("expected truncated=false (no per-version-id fan-out hit cap)")
}
if len(hosts) != 0 {
t.Errorf("expected 0 hosts (titles had no versions), got %d", len(hosts))
}
if got := titlesCalls.Load(); got != 2 {
t.Errorf("expected 2 titles pages (100 + 30 short page), got %d", got)
}
}
// campaignTestServer stands up an httptest server that answers the campaign
// create POST and upgrades the results websocket, then hands the connection to
// drive() (after consuming the auth + select_campaign handshake) so each test
// can script the frames the server sends back.
func campaignTestServer(t *testing.T, campaignID uint, drive func(conn *websocket.Conn)) *httptest.Server {
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/fleet/reports/run":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"campaign": map[string]interface{}{"id": campaignID},
})
case r.URL.Path == "/api/v1/fleet/results/websocket":
conn, err := up.Upgrade(w, r, nil)
if err != nil {
t.Errorf("websocket upgrade: %v", err)
return
}
defer conn.Close()
// Validate the client speaks the handshake protocol the real server
// enforces: an auth frame carrying a token, then a select_campaign
// frame naming this campaign.
var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil {
t.Errorf("read auth frame: %v", err)
return
}
if msg["type"] != "auth" {
t.Errorf("first frame type = %v, want auth", msg["type"])
}
if data, _ := msg["data"].(map[string]interface{}); data["token"] == "" || data["token"] == nil {
t.Errorf("auth frame missing token, got %v", msg["data"])
}
if err := conn.ReadJSON(&msg); err != nil {
t.Errorf("read select_campaign frame: %v", err)
return
}
if msg["type"] != "select_campaign" {
t.Errorf("second frame type = %v, want select_campaign", msg["type"])
}
// JSON numbers decode to float64 in an interface{} map.
if data, _ := msg["data"].(map[string]interface{}); data["campaign_id"] != float64(campaignID) {
t.Errorf("select_campaign campaign_id = %v, want %d", data["campaign_id"], campaignID)
}
drive(conn)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
}
func writeWSFrame(t *testing.T, conn *websocket.Conn, typ string, data interface{}) {
if err := conn.WriteJSON(map[string]interface{}{"type": typ, "data": data}); err != nil {
t.Errorf("server write %s frame: %v", typ, err)
}
}
// runMultiHostCampaign creates an ad-hoc campaign and streams results over the
// websocket, aggregating each host's rows into one result.
func TestRunMultiHostCampaign_AggregatesResults(t *testing.T) {
t.Setenv("FLEET_LIVE_QUERY_REST_PERIOD", "5s")
srv := campaignTestServer(t, 42, func(conn *websocket.Conn) {
writeWSFrame(t, conn, "totals", map[string]interface{}{"count": 2, "online": 2})
writeWSFrame(t, conn, "result", map[string]interface{}{
"host": map[string]interface{}{"id": 10, "hostname": "h10", "display_name": "Host 10"},
"rows": []map[string]string{{"answer": "42"}},
})
writeWSFrame(t, conn, "result", map[string]interface{}{
"host": map[string]interface{}{"id": 20, "hostname": "h20", "display_name": "Host 20"},
"rows": []map[string]string{{"answer": "43"}},
})
writeWSFrame(t, conn, "status", map[string]interface{}{"expected_results": 2, "actual_results": 2, "status": "finished"})
})
defer srv.Close()
fc := newTestClient(srv.URL)
nameByID := map[uint]Endpoint{10: {ID: 10, Name: "host-10"}, 20: {ID: 20, Name: "host-20"}}
res, err := fc.runMultiHostCampaign(t.Context(), []uint{10, 20}, "SELECT 1;", nameByID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.TargetedHostCount != 2 {
t.Errorf("TargetedHostCount = %d, want 2 (from totals)", res.TargetedHostCount)
}
if res.RespondedHostCount != 2 {
t.Errorf("RespondedHostCount = %d, want 2 (from status.actual_results)", res.RespondedHostCount)
}
if len(res.Results) != 2 {
t.Fatalf("len(Results) = %d, want 2", len(res.Results))
}
// The locally-resolved host name wins over the server-reported one.
for _, row := range res.Results {
if row["host_id"] == uint(10) && row["host_name"] != "host-10" {
t.Errorf("host 10 name = %v, want host-10", row["host_name"])
}
}
}
// Offline hosts never report, so the stream stops once every online host has
// responded rather than waiting out the deadline.
func TestRunMultiHostCampaign_StopsWhenOnlineHostsRespond(t *testing.T) {
t.Setenv("FLEET_LIVE_QUERY_REST_PERIOD", "5s")
srv := campaignTestServer(t, 7, func(conn *websocket.Conn) {
// 3 targeted, only 2 online; host 30 is offline and silent.
writeWSFrame(t, conn, "totals", map[string]interface{}{"count": 3, "online": 2})
writeWSFrame(t, conn, "result", map[string]interface{}{
"host": map[string]interface{}{"id": 10}, "rows": []map[string]string{{"k": "v"}},
})
writeWSFrame(t, conn, "result", map[string]interface{}{
"host": map[string]interface{}{"id": 20}, "rows": []map[string]string{{"k": "v"}},
})
writeWSFrame(t, conn, "status", map[string]interface{}{"expected_results": 2, "actual_results": 2, "status": "finished"})
})
defer srv.Close()
fc := newTestClient(srv.URL)
nameByID := map[uint]Endpoint{10: {ID: 10}, 20: {ID: 20}, 30: {ID: 30}}
start := time.Now()
res, err := fc.runMultiHostCampaign(t.Context(), []uint{10, 20, 30}, "SELECT 1;", nameByID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if elapsed := time.Since(start); elapsed > 4*time.Second {
t.Errorf("expected prompt return once online hosts responded, took %s", elapsed)
}
if res.TargetedHostCount != 3 {
t.Errorf("TargetedHostCount = %d, want 3", res.TargetedHostCount)
}
if res.RespondedHostCount != 2 {
t.Errorf("RespondedHostCount = %d, want 2", res.RespondedHostCount)
}
if len(res.Results) != 2 {
t.Errorf("len(Results) = %d, want 2 (offline host produced no row)", len(res.Results))
}
}
// A per-host osquery error in a result frame surfaces as an error on that host's
// row without failing the whole query.
func TestRunMultiHostCampaign_HostErrorRow(t *testing.T) {
t.Setenv("FLEET_LIVE_QUERY_REST_PERIOD", "5s")
srv := campaignTestServer(t, 1, func(conn *websocket.Conn) {
writeWSFrame(t, conn, "totals", map[string]interface{}{"count": 1, "online": 1})
writeWSFrame(t, conn, "result", map[string]interface{}{
"host": map[string]interface{}{"id": 10},
"rows": []map[string]string{},
"error": "no such table: bogus",
})
writeWSFrame(t, conn, "status", map[string]interface{}{"expected_results": 1, "actual_results": 1, "status": "finished"})
})
defer srv.Close()
fc := newTestClient(srv.URL)
res, err := fc.runMultiHostCampaign(t.Context(), []uint{10}, "SELECT * FROM bogus;", map[uint]Endpoint{10: {ID: 10}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Results) != 1 {
t.Fatalf("len(Results) = %d, want 1", len(res.Results))
}
if res.Results[0]["error"] != "no such table: bogus" {
t.Errorf("expected host error row, got %+v", res.Results[0])
}
}
// A failed campaign creation surfaces as an error (no websocket is opened).
func TestRunMultiHostCampaign_CreateFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/fleet/results/websocket" {
t.Errorf("websocket must not be dialed when campaign creation fails")
}
http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError)
}))
defer srv.Close()
fc := newTestClient(srv.URL)
_, err := fc.runMultiHostCampaign(t.Context(), []uint{10, 20}, "SELECT 1;", map[uint]Endpoint{})
if err == nil {
t.Fatal("expected error on failed campaign creation, got nil")
}
}
// An "error" frame from the server (campaign not found, unauthorized, pubsub
// failure) must surface as an error, not a silent empty result.
func TestRunMultiHostCampaign_ServerErrorFrame(t *testing.T) {
t.Setenv("FLEET_LIVE_QUERY_REST_PERIOD", "5s")
srv := campaignTestServer(t, 99, func(conn *websocket.Conn) {
writeWSFrame(t, conn, "error", "cannot find campaign for ID 99")
})
defer srv.Close()
fc := newTestClient(srv.URL)
_, err := fc.runMultiHostCampaign(t.Context(), []uint{10, 20}, "SELECT 1;", map[uint]Endpoint{})
if err == nil {
t.Fatal("expected error from server error frame, got nil")
}
if !strings.Contains(err.Error(), "cannot find campaign") {
t.Errorf("error %q should include the server's message", err)
}
}
// A mid-stream read failure (connection dropped before a terminal status) must
// surface as an error rather than masquerading as an empty successful result.
func TestRunMultiHostCampaign_StreamReadError(t *testing.T) {
t.Setenv("FLEET_LIVE_QUERY_REST_PERIOD", "5s")
srv := campaignTestServer(t, 5, func(conn *websocket.Conn) {
// Two hosts online but only one responds, then the connection drops
// abruptly (no "finished" status, no clean close handshake).
writeWSFrame(t, conn, "totals", map[string]interface{}{"count": 2, "online": 2})
writeWSFrame(t, conn, "result", map[string]interface{}{
"host": map[string]interface{}{"id": 10}, "rows": []map[string]string{{"k": "v"}},
})
conn.Close()
})
defer srv.Close()
fc := newTestClient(srv.URL)
_, err := fc.runMultiHostCampaign(t.Context(), []uint{10, 20}, "SELECT 1;", map[uint]Endpoint{10: {ID: 10}, 20: {ID: 20}})
if err == nil {
t.Fatal("expected error on abrupt stream drop, got nil")
}
}
func TestCampaignWebsocketURL(t *testing.T) {
cases := []struct {
base string
want string
}{
{"http://localhost:8080", "ws://localhost:8080/api/v1/fleet/results/websocket"},
{"https://fleet.example.com", "wss://fleet.example.com/api/v1/fleet/results/websocket"},
{"https://fleet.example.com/", "wss://fleet.example.com/api/v1/fleet/results/websocket"},
}
for _, tc := range cases {
fc := newTestClient(tc.base)
got, err := fc.campaignWebsocketURL()
if err != nil {
t.Errorf("campaignWebsocketURL(%q) error: %v", tc.base, err)
continue
}
if got != tc.want {
t.Errorf("campaignWebsocketURL(%q) = %q, want %q", tc.base, got, tc.want)
}
}
}
func TestListSoftwareTitles_PaginatesUntilShortPage(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/fleet/software/titles" {
t.Errorf("unexpected path %q", r.URL.Path)
http.NotFound(w, r)
return
}
calls.Add(1)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
var titles []SoftwareTitle
switch page {
case 0:
titles = make([]SoftwareTitle, 100)
for i := range titles {
titles[i] = SoftwareTitle{ID: uint(i + 1), Name: fmt.Sprintf("pkg%d", i), Source: "apps"}
}
case 1:
titles = make([]SoftwareTitle, 25)
for i := range titles {
titles[i] = SoftwareTitle{ID: uint(100 + i + 1), Name: fmt.Sprintf("pkg%d", 100+i), Source: "apps"}
}
default:
t.Errorf("unexpected page %d", page)
http.Error(w, "unexpected page", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(struct {
SoftwareTitles []SoftwareTitle `json:"software_titles"`
}{SoftwareTitles: titles})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
// perPage 0 means "no client-side cap" — paginate until the short page.
out, truncated, err := fc.ListSoftwareTitles(context.Background(), "", "", "", "", "", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if truncated {
t.Errorf("expected truncated=false")
}
if got, want := len(out), 125; got != want {
t.Errorf("len(out) = %d, want %d", got, want)
}
if got := calls.Load(); got != 2 {
t.Errorf("expected 2 page calls, got %d", got)
}
}
func TestListSoftwareTitles_AppliesSourceFilter(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/fleet/software/titles" {
http.NotFound(w, r)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page > 0 {
// Short page on page 1 to end pagination.
_ = json.NewEncoder(w).Encode(struct {
SoftwareTitles []SoftwareTitle `json:"software_titles"`
}{})
return
}
// Mixed-source payload: 3 npm, 2 python, 5 apps. Short page (8 < 100)
// so pagination ends after this response.
titles := []SoftwareTitle{
{ID: 1, Name: "left-pad", Source: "npm_packages"},
{ID: 2, Name: "lodash", Source: "npm_packages"},
{ID: 3, Name: "axios", Source: "npm_packages"},
{ID: 4, Name: "requests", Source: "python_packages"},
{ID: 5, Name: "numpy", Source: "python_packages"},
{ID: 6, Name: "Slack.app", Source: "apps"},
{ID: 7, Name: "Chrome.app", Source: "apps"},
{ID: 8, Name: "Zoom.app", Source: "apps"},
}
_ = json.NewEncoder(w).Encode(struct {
SoftwareTitles []SoftwareTitle `json:"software_titles"`
}{SoftwareTitles: titles})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
out, _, err := fc.ListSoftwareTitles(context.Background(), "", "", "", "", "npm_packages", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(out), 3; got != want {
t.Errorf("len(out) = %d, want %d (3 npm)", got, want)
}
for _, row := range out {
if !strings.EqualFold(row.Source, "npm_packages") {
t.Errorf("unexpected source %q in filtered result", row.Source)
}
}
// Case-insensitive should also work.
out2, _, err := fc.ListSoftwareTitles(context.Background(), "", "", "", "", "NPM_PACKAGES", 0)
if err != nil {
t.Fatalf("unexpected error (case-insensitive): %v", err)
}
if len(out2) != 3 {
t.Errorf("case-insensitive filter returned %d rows, want 3", len(out2))
}
}
func TestGetHostSoftware_PropagatesTruncated(t *testing.T) {
// Lower the cap so a small fixture trips truncation deterministically.
orig := fetchSoftwareHardCap
fetchSoftwareHardCap = 4
t.Cleanup(func() { fetchSoftwareHardCap = orig })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/fleet/hosts/") || !strings.HasSuffix(r.URL.Path, "/software") {
http.NotFound(w, r)
return
}
// Single page with 10 matching rows — hard cap of 4 should fire
// before the page is fully consumed.
rows := make([]HostSoftware, 10)
for i := range rows {
rows[i] = HostSoftware{ID: uint(i + 1), Name: fmt.Sprintf("pkg%d", i), Source: "apps"}
}
_ = json.NewEncoder(w).Encode(struct {
Software []HostSoftware `json:"software"`
}{Software: rows})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
// perPage 0 — don't short-circuit on client-side cap. Force the hard-cap
// path to fire instead. source="" matches everything.
out, truncated, err := fc.GetHostSoftware(context.Background(), 42, "", "", "", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !truncated {
t.Errorf("expected truncated=true when hard cap fires")
}
if got, want := len(out), 4; got != want {
t.Errorf("len(out) = %d, want %d (hard cap)", got, want)
}
}
func TestResolveHostWithUsers_AmbiguousCandidates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/fleet/hosts":
// Substring search returns multiple collisions.
hosts := []Endpoint{
{ID: 1, Name: "mac-1.local"},
{ID: 2, Name: "mac-2.local"},
{ID: 3, Name: "mac-3.local"},
}
_ = json.NewEncoder(w).Encode(struct {
Hosts []Endpoint `json:"hosts"`
}{Hosts: hosts})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
fc := newTestClient(srv.URL)
host, ambiguous, candidates, err := resolveHostWithUsers(context.Background(), fc, 0, "mac")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ambiguous {
t.Errorf("expected ambiguous=true for multi-match identifier")
}
if host != nil {
t.Errorf("expected host=nil when ambiguous, got %+v", host)
}
if got, want := len(candidates), 3; got != want {
t.Errorf("len(candidates) = %d, want %d", got, want)
}
}
func TestGetHostByIDWithUsers_DecodesUsers(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/fleet/hosts/42" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"host": map[string]any{
"id": 42,
"hostname": "test.local",
"users": []map[string]any{
{"uid": 501, "username": "alice", "type": "regular", "groupname": "staff", "shell": "/bin/zsh"},
{"uid": 502, "username": "bob", "type": "regular", "groupname": "staff", "shell": "/bin/bash"},
},
},
})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
host, err := fc.GetHostByIDWithUsers(context.Background(), 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host == nil {
t.Fatalf("nil host")
}
if got, want := host.ID, uint(42); got != want {
t.Errorf("host.ID = %d, want %d", got, want)
}
if got, want := len(host.Users), 2; got != want {
t.Errorf("len(users) = %d, want %d", got, want)
}
if host.Users[0].Username != "alice" || host.Users[1].Shell != "/bin/bash" {
t.Errorf("user decode mismatch: %+v", host.Users)
}
}
func TestFilterHostUsers_CaseInsensitiveAcrossFields(t *testing.T) {
users := []HostUser{
{UID: 501, Username: "alice", GroupName: "staff", Shell: "/bin/zsh"},
{UID: 502, Username: "bob", GroupName: "wheel", Shell: "/bin/bash"},
{UID: 0, Username: "root", GroupName: "wheel", Shell: "/bin/sh"},
}
cases := []struct {
query string
want int
}{
{"alice", 1}, // username exact
{"ALICE", 1}, // case-insensitive
{"wheel", 2}, // groupname
{"bash", 1}, // shell
{"50", 2}, // uid prefix (matches 501, 502)
{"nomatch", 0},
}
for _, tc := range cases {
got := filterHostUsers(users, tc.query)
if len(got) != tc.want {
t.Errorf("filterHostUsers(%q) returned %d, want %d", tc.query, len(got), tc.want)
}
}
}
func TestValidateGetSoftwareArgs(t *testing.T) {
cases := []struct {
name string
perHost bool
fleet, platform, vulnerable string
wantErr bool
}{
{"per-host alone ok", true, "", "", "", false},
{"per-host + fleet rejected", true, "Workstations", "", "", true},
{"per-host + platform rejected", true, "", "macos", "", true},
{"cross-host none ok (full inventory)", false, "", "", "", false},
{"cross-host fleet alone ok", false, "Workstations", "", "", false},
{"cross-host platform alone rejected", false, "", "macos", "", true},
{"cross-host platform + fleet ok", false, "Workstations", "macos", "", false},
{"vulnerable=true ok", false, "", "", "true", false},
{"vulnerable=false ok", false, "", "", "false", false},
{"vulnerable bad value rejected", false, "", "", "maybe", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateGetSoftwareArgs(tc.perHost, tc.fleet, tc.platform, tc.vulnerable)
if tc.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestMatchesSoftwareSource(t *testing.T) {
cases := []struct {
row, want string
expect bool
}{
{"apps", "", true}, // empty want matches anything
{"apps", "apps", true}, // exact
{"NPM_Packages", "npm_packages", true}, // case-insensitive
{"deb_packages", "apps", false}, // mismatch
}
for _, tc := range cases {
if got := matchesSoftwareSource(tc.row, tc.want); got != tc.expect {
t.Errorf("matchesSoftwareSource(%q,%q) = %v, want %v", tc.row, tc.want, got, tc.expect)
}
}
}
func TestResolveHost_NumericFetchesByID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/fleet/hosts/42" {
_ = json.NewEncoder(w).Encode(struct {
Host Endpoint `json:"host"`
}{Host: Endpoint{ID: 42, Name: "h42.local"}})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
fc := newTestClient(srv.URL)
// numeric host_id is verified via GetHostByID (confirms it exists, gets the name)
host, _, ambiguous, err := resolveHost(context.Background(), fc, 42, "")
if err != nil || ambiguous || host == nil || host.ID != 42 || host.Name != "h42.local" {
t.Fatalf("numeric: host=%+v ambiguous=%v err=%v, want id=42 with name", host, ambiguous, err)
}
}
func TestParseHostIDArg(t *testing.T) {
cases := []struct {
in string
want uint
wantErr bool
}{
{"", 0, false},
{"42", 42, false},
{"abc", 0, true},
{"0", 0, true},
{"-1", 0, true},
}
for _, tc := range cases {
got, err := parseHostIDArg(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("parseHostIDArg(%q): expected error, got nil", tc.in)
}
continue
}
if err != nil || got != tc.want {
t.Errorf("parseHostIDArg(%q) = (%d, %v), want (%d, nil)", tc.in, got, err, tc.want)
}
}
}
func TestResolveHost_IdentifierSingleAndFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/fleet/hosts":
hosts := []Endpoint{}
if r.URL.Query().Get("query") == "solo" { // single unambiguous match
hosts = []Endpoint{{ID: 7, Name: "solo.local"}}
}
_ = json.NewEncoder(w).Encode(struct {
Hosts []Endpoint `json:"hosts"`
}{Hosts: hosts})
case r.URL.Path == "/api/v1/fleet/hosts/identifier/ghost":
_ = json.NewEncoder(w).Encode(struct {
Host Endpoint `json:"host"`
}{Host: Endpoint{ID: 9, Name: "ghost.local"}})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
fc := newTestClient(srv.URL)
// single substring match -> that host, not ambiguous
host, _, ambiguous, err := resolveHost(context.Background(), fc, 0, "solo")
if err != nil || ambiguous || host == nil || host.ID != 7 {
t.Fatalf("single match: host=%+v ambiguous=%v err=%v, want id=7", host, ambiguous, err)
}
// zero substring matches -> identifier-endpoint fallback
host, _, ambiguous, err = resolveHost(context.Background(), fc, 0, "ghost")
if err != nil || ambiguous || host == nil || host.ID != 9 {
t.Fatalf("fallback: host=%+v ambiguous=%v err=%v, want id=9", host, ambiguous, err)
}
}
func TestResolveHostWithUsers_SingleMatchAndFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/fleet/hosts":
hosts := []Endpoint{}
if r.URL.Query().Get("query") == "solo" {
hosts = []Endpoint{{ID: 5, Name: "solo.local"}}
}
_ = json.NewEncoder(w).Encode(struct {
Hosts []Endpoint `json:"hosts"`
}{Hosts: hosts})
case "/api/v1/fleet/hosts/5":
_ = json.NewEncoder(w).Encode(struct {
Host HostWithUsers `json:"host"`
}{Host: HostWithUsers{Endpoint: Endpoint{ID: 5, Name: "solo.local"}, Users: []HostUser{{UID: 501, Username: "alice"}}}})
case "/api/v1/fleet/hosts/identifier/ghost":
// identifier endpoint resolves the host but carries NO users
_ = json.NewEncoder(w).Encode(struct {
Host Endpoint `json:"host"`
}{Host: Endpoint{ID: 9, Name: "ghost.local"}})
case "/api/v1/fleet/hosts/9":
// users come from the by-id refetch
_ = json.NewEncoder(w).Encode(struct {
Host HostWithUsers `json:"host"`
}{Host: HostWithUsers{Endpoint: Endpoint{ID: 9, Name: "ghost.local"}, Users: []HostUser{{UID: 0, Username: "root"}}}})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
fc := newTestClient(srv.URL)
// single match -> fetched by id, users populated
host, ambiguous, _, err := resolveHostWithUsers(context.Background(), fc, 0, "solo")
if err != nil || ambiguous || host == nil || host.ID != 5 || len(host.Users) != 1 {
t.Fatalf("single match: host=%+v ambiguous=%v err=%v", host, ambiguous, err)
}
// zero matches -> identifier endpoint (no users) then by-id refetch (users)
host, ambiguous, _, err = resolveHostWithUsers(context.Background(), fc, 0, "ghost")
if err != nil || ambiguous || host == nil || host.ID != 9 || len(host.Users) != 1 || host.Users[0].Username != "root" {
t.Fatalf("fallback: host=%+v ambiguous=%v err=%v", host, ambiguous, err)
}
}
func TestGetHostSoftware_DecodesNestedInstalledVersions(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/software") {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"software":[{"id":1,"name":"curl","source":"deb_packages","installed_versions":[{"version":"7.88.1","vulnerabilities":["CVE-2026-1111"],"installed_paths":["/usr/bin/curl"]}]}]}`))
}))
defer srv.Close()
fc := newTestClient(srv.URL)
out, _, err := fc.GetHostSoftware(context.Background(), 1, "", "", "", 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 1 || len(out[0].InstalledVersions) != 1 {
t.Fatalf("decoded = %+v, want 1 row with 1 installed version", out)
}
v := out[0].InstalledVersions[0]
if v.Version != "7.88.1" || len(v.Vulnerabilities) != 1 || v.Vulnerabilities[0] != "CVE-2026-1111" || len(v.InstalledPaths) != 1 {
t.Errorf("nested installed_version not decoded: %+v", v)
}
}
func TestGetHostSoftware_SourceFilterAndPerPage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/software") {
http.NotFound(w, r)
return
}
rows := []HostSoftware{
{ID: 1, Name: "a", Source: "apps"},
{ID: 2, Name: "b", Source: "deb_packages"},
{ID: 3, Name: "c", Source: "apps"},
{ID: 4, Name: "d", Source: "npm_packages"},
{ID: 5, Name: "e", Source: "apps"},
}
_ = json.NewEncoder(w).Encode(struct {
Software []HostSoftware `json:"software"`
}{Software: rows})
}))
defer srv.Close()
fc := newTestClient(srv.URL)
// source=apps keeps only apps rows; perPage=2 caps the merged result early
out, truncated, err := fc.GetHostSoftware(context.Background(), 42, "", "", "apps", 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if truncated {
t.Errorf("expected truncated=false when perPage is reached")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (perPage cap on matching rows)", len(out))
}
for _, sw := range out {
if sw.Source != "apps" {
t.Errorf("source filter leaked non-apps row: %+v", sw)
}
}
}
func TestGetPolicies_IncludesQueryField(t *testing.T) {
const wantSQL = "SELECT 1;"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/fleet/global/policies":
_, _ = w.Write([]byte(`{"policies":[
{"id":1,"name":"with sql","query":"` + wantSQL + `"},
{"id":2,"name":"empty sql","query":""}
]}`))
case "/api/v1/fleet/fleets":
_, _ = w.Write([]byte(`{"teams":[]}`))
default:
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
}
}))
defer srv.Close()
fc := newTestClient(srv.URL)
policies, err := fc.GetPolicies(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(policies) != 2 {
t.Fatalf("expected 2 policies, got %d", len(policies))
}
if policies[0].Query != wantSQL {
t.Errorf("policy 1 Query = %q, want %q", policies[0].Query, wantSQL)
}
if policies[1].Query != "" {
t.Errorf("policy 2 Query = %q, want empty string", policies[1].Query)
}
}