mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> Resolves #40593 This PR attempts to fix this error: ``` {"component":"http","err":"associating asset with adamID <adamId> to host <hostId>: making request to Apple VPP endpoint: making request to Apple VPP endpoint: Post \"https://vpp.itunes.apple.com/mdm/v2/assets/associate\": http: ContentLength=111 with Body length 0","host_id":<hostId>,"ip_addr":"<ip_addr>","level":"error","method":"POST","took":"20.748056032s","ts":"2026-02-25T09:53:32.10267006Z","uri":"/api/latest/fleet/device/<deviceId>/software/install/<id>","x_for_ip_addr":"<ip_addr>"} ``` Per my troubleshooting: `client.Do(req)` consumes the request body. When retrying, the same `req` is reused but its body is not there -- so, the retry sends `ContentLength=108` with an empty body, producing the `Body length 0` error. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually Ran the test I added without the code fix, and was able to see the exact same error <img width="1188" height="567" alt="Screenshot 2026-02-25 at 3 26 12 PM" src="https://github.com/user-attachments/assets/d7bdfee7-de33-43d0-92c6-e77fa46329d6" /> After: <img width="852" height="140" alt="Screenshot 2026-02-25 at 3 26 55 PM" src="https://github.com/user-attachments/assets/e7ec3ea5-2b29-463a-9038-e5530d654a4d" />
466 lines
13 KiB
Go
466 lines
13 KiB
Go
package vpp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
|
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func setupFakeServer(t *testing.T, handler http.HandlerFunc) {
|
|
server := httptest.NewServer(handler)
|
|
dev_mode.SetOverride("FLEET_DEV_VPP_URL", server.URL, t)
|
|
t.Cleanup(server.Close)
|
|
}
|
|
|
|
func TestGetConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
handler http.HandlerFunc
|
|
wantName string
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "valid token",
|
|
token: "valid_token",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintln(w, `{"locationName": "Test Location"}`)
|
|
},
|
|
wantName: "Test Location",
|
|
expectedErrMsg: "",
|
|
},
|
|
{
|
|
name: "invalid token",
|
|
token: "invalid_token",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
fmt.Fprintln(w, `{"errorNumber": 9622}`)
|
|
},
|
|
wantName: "",
|
|
expectedErrMsg: "making request to Apple VPP endpoint: Apple VPP endpoint returned error: (error number: 9622)",
|
|
},
|
|
{
|
|
name: "server error",
|
|
token: "valid_token",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintln(w, `Internal Server Error`)
|
|
},
|
|
wantName: "",
|
|
expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
setupFakeServer(t, tt.handler)
|
|
|
|
name, err := GetConfig(tt.token)
|
|
if tt.expectedErrMsg != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.expectedErrMsg)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
require.Equal(t, tt.wantName, name)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssociateAssets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
params *AssociateAssetsRequest
|
|
handler http.HandlerFunc
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "valid request",
|
|
token: "valid_token",
|
|
params: &AssociateAssetsRequest{
|
|
Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}},
|
|
SerialNumbers: []string{"SN12345"},
|
|
},
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
require.Equal(t, "/assets/associate", r.URL.Path)
|
|
require.Equal(t, "Bearer valid_token", r.Header.Get("Authorization"))
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
|
|
var reqParams AssociateAssetsRequest
|
|
err = json.Unmarshal(body, &reqParams)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, reqParams.Assets)
|
|
require.Equal(t, []string{"SN12345"}, reqParams.SerialNumbers)
|
|
|
|
_, _ = w.Write([]byte(`{"eventId": "123"}`))
|
|
},
|
|
expectedErrMsg: "",
|
|
},
|
|
{
|
|
name: "server error",
|
|
token: "valid_token",
|
|
params: &AssociateAssetsRequest{
|
|
Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}},
|
|
SerialNumbers: []string{"SN12345"},
|
|
},
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintln(w, `Internal Server Error`)
|
|
},
|
|
expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n",
|
|
},
|
|
{
|
|
name: "client error",
|
|
token: "valid_token",
|
|
params: &AssociateAssetsRequest{
|
|
Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}},
|
|
SerialNumbers: []string{"SN12345"},
|
|
},
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`)
|
|
},
|
|
expectedErrMsg: "making request to Apple VPP endpoint: Apple VPP endpoint returned error: Bad Request (error number: 400)",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
setupFakeServer(t, tt.handler)
|
|
|
|
_, err := AssociateAssets(tt.token, tt.params)
|
|
if tt.expectedErrMsg != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.expectedErrMsg)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAssets(t *testing.T) {
|
|
originalClient := client
|
|
client = fleethttp.NewClient(fleethttp.WithTimeout(time.Second))
|
|
t.Cleanup(func() {
|
|
client = originalClient
|
|
})
|
|
|
|
var requestCount atomic.Int64
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
filter *AssetFilter
|
|
handler http.HandlerFunc
|
|
expectedAssets []Asset
|
|
expectedErrMsg string
|
|
expectedRequests int
|
|
}{
|
|
{
|
|
name: "valid token and filters",
|
|
token: "valid_token",
|
|
filter: &AssetFilter{
|
|
AdamID: "12345",
|
|
},
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
require.Equal(t, "/assets", r.URL.Path)
|
|
require.Equal(t, "Bearer valid_token", r.Header.Get("Authorization"))
|
|
|
|
query := r.URL.Query()
|
|
require.Equal(t, "12345", query.Get("adamId"))
|
|
|
|
type resp struct {
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
assets := resp{
|
|
Assets: []Asset{
|
|
{AdamID: "12345", PricingParam: "STDQ"},
|
|
{AdamID: "67890", PricingParam: "PLUS"},
|
|
},
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
require.NoError(t, json.NewEncoder(w).Encode(assets))
|
|
},
|
|
expectedAssets: []Asset{
|
|
{AdamID: "12345", PricingParam: "STDQ"},
|
|
{AdamID: "67890", PricingParam: "PLUS"},
|
|
},
|
|
expectedErrMsg: "",
|
|
expectedRequests: 1,
|
|
},
|
|
{
|
|
name: "server error",
|
|
token: "valid_token",
|
|
filter: nil,
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintln(w, `Internal Server Error`)
|
|
},
|
|
expectedAssets: nil,
|
|
expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n",
|
|
expectedRequests: 1,
|
|
},
|
|
{
|
|
name: "client error",
|
|
token: "valid_token",
|
|
filter: nil,
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`)
|
|
},
|
|
expectedAssets: nil,
|
|
expectedErrMsg: "retrieving assets: Apple VPP endpoint returned error: Bad Request (error number: 400)",
|
|
expectedRequests: 1,
|
|
},
|
|
{
|
|
name: "always times out",
|
|
token: "valid_token",
|
|
filter: nil,
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(time.Second + 500*time.Millisecond) // longer than the 1s client timeout
|
|
type resp struct {
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
assets := resp{
|
|
Assets: []Asset{
|
|
{AdamID: "12345", PricingParam: "STDQ"},
|
|
{AdamID: "67890", PricingParam: "PLUS"},
|
|
},
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
require.NoError(t, json.NewEncoder(w).Encode(assets))
|
|
},
|
|
expectedAssets: nil,
|
|
expectedErrMsg: "exceeded",
|
|
expectedRequests: 3,
|
|
},
|
|
{
|
|
name: "times out then valid",
|
|
token: "valid_token",
|
|
filter: nil,
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
if requestCount.Load() < 2 {
|
|
time.Sleep(time.Second + 500*time.Millisecond) // longer than the 1s client timeout
|
|
}
|
|
|
|
type resp struct {
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
assets := resp{
|
|
Assets: []Asset{
|
|
{AdamID: "12345", PricingParam: "STDQ"},
|
|
{AdamID: "67890", PricingParam: "PLUS"},
|
|
},
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
require.NoError(t, json.NewEncoder(w).Encode(assets))
|
|
},
|
|
expectedAssets: []Asset{
|
|
{AdamID: "12345", PricingParam: "STDQ"},
|
|
{AdamID: "67890", PricingParam: "PLUS"},
|
|
},
|
|
expectedErrMsg: "",
|
|
expectedRequests: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
requestCount.Store(0)
|
|
|
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestCount.Add(1)
|
|
tt.handler(w, r)
|
|
})
|
|
setupFakeServer(t, h)
|
|
|
|
assets, err := GetAssets(t.Context(), tt.token, tt.filter)
|
|
if tt.expectedErrMsg != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.expectedErrMsg)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expectedAssets, assets)
|
|
}
|
|
require.EqualValues(t, tt.expectedRequests, requestCount.Load())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDoRetryAfter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
wantCalls int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "no retry-after header",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, err := w.Write([]byte("{}"))
|
|
require.NoError(t, err)
|
|
},
|
|
wantCalls: 1,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid retry-after header",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add("Retry-After", "foo")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, err := w.Write([]byte("{}"))
|
|
require.NoError(t, err)
|
|
},
|
|
wantCalls: 1,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "three retries",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add("Retry-After", "1")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, err := w.Write([]byte("{}"))
|
|
require.NoError(t, err)
|
|
},
|
|
wantCalls: 3,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var calls int
|
|
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if calls < tt.wantCalls {
|
|
tt.handler(w, r)
|
|
return
|
|
}
|
|
})
|
|
|
|
start := time.Now()
|
|
req, err := http.NewRequest(http.MethodGet, dev_mode.Env("FLEET_DEV_VPP_URL"), nil)
|
|
require.NoError(t, err)
|
|
err = do[any](req, "test-token", nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.wantCalls, calls)
|
|
require.WithinRange(t, time.Now(), start, start.Add(time.Duration(tt.wantCalls)*time.Second))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDoRetry(t *testing.T) {
|
|
t.Run("retries after 500 with Retry-After", func(t *testing.T) {
|
|
var calls int
|
|
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
|
|
// Verify Authorization header appears exactly once
|
|
authHeaders := r.Header.Values("Authorization")
|
|
require.Len(t, authHeaders, 1,
|
|
"expected exactly 1 Authorization header on attempt %d, got %d: %v",
|
|
calls, len(authHeaders), authHeaders)
|
|
require.Equal(t, "Bearer test-token", authHeaders[0])
|
|
|
|
// Verify POST body is intact
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, body, "request body should not be empty on attempt %d", calls)
|
|
|
|
var reqParams AssociateAssetsRequest
|
|
err = json.Unmarshal(body, &reqParams)
|
|
require.NoError(t, err, "request body should be valid JSON on attempt %d, got: %q", calls, string(body))
|
|
require.Equal(t, "462054704", reqParams.Assets[0].AdamID)
|
|
require.Equal(t, "GXH409KH7X", reqParams.SerialNumbers[0])
|
|
|
|
if calls == 1 {
|
|
// First call: return 500 with Retry-After to trigger retry
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("{}"))
|
|
return
|
|
}
|
|
|
|
// Second call: success
|
|
_, _ = w.Write([]byte(`{"eventId": "evt-123"}`))
|
|
})
|
|
|
|
eventID, err := AssociateAssets("test-token", &AssociateAssetsRequest{
|
|
Assets: []Asset{{AdamID: "462054704", PricingParam: "STDQ"}},
|
|
SerialNumbers: []string{"GXH409KH7X"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "evt-123", eventID)
|
|
require.Equal(t, 2, calls)
|
|
})
|
|
|
|
t.Run("retries after error 9646", func(t *testing.T) {
|
|
var calls int
|
|
setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
|
|
// Verify Authorization header appears exactly once
|
|
authHeaders := r.Header.Values("Authorization")
|
|
require.Len(t, authHeaders, 1,
|
|
"expected exactly 1 Authorization header on attempt %d, got %d: %v",
|
|
calls, len(authHeaders), authHeaders)
|
|
|
|
// Verify POST body is intact
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, body, "request body should not be empty on attempt %d", calls)
|
|
|
|
var reqParams AssociateAssetsRequest
|
|
err = json.Unmarshal(body, &reqParams)
|
|
require.NoError(t, err, "request body should be valid JSON on attempt %d, got: %q", calls, string(body))
|
|
require.Equal(t, "462054704", reqParams.Assets[0].AdamID)
|
|
|
|
if calls == 1 {
|
|
// First call: return rate-limit error 9646
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"errorMessage":"Too many requests","errorNumber":9646}`))
|
|
return
|
|
}
|
|
|
|
// Second call: success
|
|
_, _ = w.Write([]byte(`{"eventId": "evt-456"}`))
|
|
})
|
|
|
|
eventID, err := AssociateAssets("test-token", &AssociateAssetsRequest{
|
|
Assets: []Asset{{AdamID: "462054704", PricingParam: "STDQ"}},
|
|
SerialNumbers: []string{"GXH409KH7X"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "evt-456", eventID)
|
|
require.GreaterOrEqual(t, calls, 2)
|
|
})
|
|
}
|
|
|
|
func TestGetBaseURL(t *testing.T) {
|
|
t.Run("Default URL", func(t *testing.T) {
|
|
require.Equal(t, "https://vpp.itunes.apple.com/mdm/v2", getBaseURL())
|
|
})
|
|
|
|
t.Run("Custom URL", func(t *testing.T) {
|
|
customURL := "http://localhost:8000"
|
|
dev_mode.SetOverride("FLEET_DEV_VPP_URL", customURL, t)
|
|
require.Equal(t, customURL, getBaseURL())
|
|
})
|
|
}
|