Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/fleetctl/fleetctl/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2159,7 +2159,7 @@ func TestGitOpsFullGlobal(t *testing.T) {

// App config
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}}, nil
}
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
savedAppConfig = config
Expand Down
36 changes: 34 additions & 2 deletions server/service/integration_core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6159,10 +6159,35 @@ func (s *integrationTestSuite) TestListHostsByLabel() {
),
)

// The device_mapping is built with GROUP_CONCAT, whose element order is not
// guaranteed, so normalize the ordering before comparing the two responses.
normalizeDeviceMapping := func(resp *listHostsResponse) {
for i := range resp.Hosts {
dm := resp.Hosts[i].DeviceMapping
if dm == nil {
continue
}
var mappings []fleet.HostDeviceMapping
require.NoError(t, json.Unmarshal(*dm, &mappings))
sort.Slice(mappings, func(a, b int) bool {
if mappings[a].Email != mappings[b].Email {
return mappings[a].Email < mappings[b].Email
}
return mappings[a].Source < mappings[b].Source
})
normalized, err := json.Marshal(mappings)
require.NoError(t, err)
raw := json.RawMessage(normalized)
resp.Hosts[i].DeviceMapping = &raw
}
}

// Now do the actual API calls that we will compare.
var hostsResp, labelsResp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hostsResp, "device_mapping", "true")
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelID), nil, http.StatusOK, &labelsResp, "device_mapping", "true")
normalizeDeviceMapping(&hostsResp)
normalizeDeviceMapping(&labelsResp)

// Converting to formatted JSON for easier diffs
hostsJson, _ := json.MarshalIndent(hostsResp, "", " ")
Expand All @@ -6172,6 +6197,8 @@ func (s *integrationTestSuite) TestListHostsByLabel() {
// Do request with include_device_status, since it's an additional feature
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hostsResp, "device_mapping", "true", "include_device_status", "true")
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelID), nil, http.StatusOK, &labelsResp, "device_mapping", "true", "include_device_status", "true")
normalizeDeviceMapping(&hostsResp)
normalizeDeviceMapping(&labelsResp)

// Converting to formatted JSON for easier diffs
hostsJson, _ = json.MarshalIndent(hostsResp, "", " ")
Expand Down Expand Up @@ -10664,14 +10691,19 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
res = s.DoRaw("GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv", "columns", "id,hostname,device_mapping")
rawCSV, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Contains(t, string(rawCSV), `"a@b.c,b@b.c"`) // inside quotes because it contains a comma
// the cell is wrapped in quotes because it contains a comma; the order of the
// emails within it is not guaranteed (GROUP_CONCAT), so accept either order.
require.True(t,
strings.Contains(string(rawCSV), `"a@b.c,b@b.c"`) || strings.Contains(string(rawCSV), `"b@b.c,a@b.c"`),
string(rawCSV))
rows, err = csv.NewReader(bytes.NewReader(rawCSV)).ReadAll()
res.Body.Close()
require.NoError(t, err)
require.Len(t, rows, len(hosts)+1)
for _, row := range rows[1:] {
if row[0] == fmt.Sprint(hosts[2].ID) {
require.Equal(t, "a@b.c,b@b.c", row[2], row)
// the order of the emails is not guaranteed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the order of emails supposed to be guaranteed?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we have some inconsistency in our device mapping API sorting:

Fleet has two code paths that turn the same host_emails rows into a device_mapping array, and today they
disagree:

Path A — sorted (ORDER BY email, source) via listHostDeviceMappingDB → ListHostDeviceMapping. This
serves:

  • GET /hosts/{id}/device_mapping — the host details page (hosts.go:2240)
  • PUT /hosts/{id}/device_mapping — returns the updated list after adding a custom email (hosts.go:2357)
  • GET /device/{token}/device_mapping — the end-user "My Device" page (devices.go:420)

Path B — unordered (GROUP_CONCAT with no ORDER BY) via the list query. This serves:

  • GET /hosts?device_mapping=true — the Hosts table (hosts.go:1311)
  • GET /labels/{id}/hosts?device_mapping=true — a label's host list (labels.go:1237)
  • GET /hosts/report — the CSV export (same query)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe ok to merge this fix as is and file a new bug to fix the inconsistencies, TMWYT

@juan-fdz-hawa juan-fdz-hawa Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my guess is that users don't care about the ordering (no bug reported yet), so that's why it made sense to fix this at the test layer instead of changing production code ATM.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require.ElementsMatch(t, []string{"a@b.c", "b@b.c"}, strings.Split(row[2], ","), row)
} else {
require.Equal(t, "", row[2], row)
}
Expand Down
Loading