From ca259fbfb7a09e4c2fef09dc5d9967ffd5f4fcbc Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 22 Oct 2026 01:44:07 +0000 Subject: [PATCH] api/v2: send empty route lists under fields=all --- hscontrol/api/v2/devices.go | 6 +++--- hscontrol/apiv2_compat_test.go | 9 --------- hscontrol/apiv2_devices_test.go | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 10f07eb06..a5fbe4547 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -46,9 +46,9 @@ type Device struct { // does not record, so it is left out. OAuthClientID string `json:"oauthClientId,omitempty"` - // Populated only when fields=all is requested. - AdvertisedRoutes []string `json:"advertisedRoutes,omitempty"` - EnabledRoutes []string `json:"enabledRoutes,omitempty"` + // Populated only when fields=all is requested, then as [] when empty. + AdvertisedRoutes []string `json:"advertisedRoutes,omitzero"` + EnabledRoutes []string `json:"enabledRoutes,omitzero"` } // DeviceRoutes is the GET /device/{id}/routes response: what the node announces diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index 76fb49991..7b2a1a363 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -99,14 +99,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/device:before/all/device-oauth-client": deviceOAuthClientMaker, "oauth/device:joiner/all/device-oauth-client": deviceOAuthClientMaker, - "oauth/device:before/empty-route-lists": emptyRouteListsOmitted, - "oauth/device:ci-runner/empty-route-lists": emptyRouteListsOmitted, - "oauth/device:joiner/empty-route-lists": emptyRouteListsOmitted, - "oauth/device:laptop/empty-route-lists": emptyRouteListsOmitted, - "oauth/device:managed/empty-route-lists": emptyRouteListsOmitted, - "oauth/device:mixed/empty-route-lists": emptyRouteListsOmitted, - "oauth/device:prod-db/empty-route-lists": emptyRouteListsOmitted, - "policy-api/P2/0/status": noV2Validate, "policy-api/P2/0/message": noV2Validate, "policy-api/P3/0/status": noV2Validate, @@ -166,7 +158,6 @@ const ( deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it" deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized" missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope" - emptyRouteListsOmitted = "EMPTY_ROUTE_LISTS_OMITTED: SaaS sends advertisedRoutes and enabledRoutes as [] for a device without routes under fields=all; headscale leaves both out" noV2Validate = "NO_V2_VALIDATE: headscale's v2 API has no /acl/validate" hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent" ) diff --git a/hscontrol/apiv2_devices_test.go b/hscontrol/apiv2_devices_test.go index 6d667702c..c5984f231 100644 --- a/hscontrol/apiv2_devices_test.go +++ b/hscontrol/apiv2_devices_test.go @@ -151,6 +151,22 @@ func TestAPIv2Device_Get(t *testing.T) { assert.Equal(t, n.IPsAsString(), dev.Addresses) } +// TestAPIv2Device_EmptyRouteLists: fields=all sends a device's route lists +// even when they are empty; the default read leaves them out. +func TestAPIv2Device_EmptyRouteLists(t *testing.T) { + e := newDeviceTestEnv(t) + + all := e.api.Get("/api/v2/device/" + e.deviceID + "?fields=all") + require.Equalf(t, http.StatusOK, all.Code, "body: %s", all.Body) + assert.Contains(t, all.Body.String(), `"advertisedRoutes":[]`) + assert.Contains(t, all.Body.String(), `"enabledRoutes":[]`) + + def := e.api.Get("/api/v2/device/" + e.deviceID) + require.Equalf(t, http.StatusOK, def.Code, "body: %s", def.Body) + assert.NotContains(t, def.Body.String(), "advertisedRoutes") + assert.NotContains(t, def.Body.String(), "enabledRoutes") +} + // TestAPIv2Device_NameIsFQDN: with a base domain, a device's name is its // MagicDNS FQDN, in a read and in the list. func TestAPIv2Device_NameIsFQDN(t *testing.T) {