15 "github.com/stretchr/testify/require"
18 func TestHTTPClientReadsAllRecordsAndOrderedActionFields(t *testing.T) {
19 var requests []*http.Request
21 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
22 requests = append(requests, request)
24 return responseWith("[" + validRecord + "]"), nil
27 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: transport})
28 require.NoError(t, err)
30 states, err := client.Status(t.Context())
31 require.NoError(t, err)
32 require.Len(t, states, 1)
33 require.Equal(t, "fc610e7f98aa", states[0].MAC)
34 require.InDelta(t, 19.5, states[0].CurrentTemperature, 0.001)
35 require.True(t, states[0].Locked)
38 state, err := client.Action(t.Context(), "FC610E7F98AA", Action{TargetTemperature: &target})
39 require.NoError(t, err)
40 require.Equal(t, states[0], state)
43 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{Power: &power})
44 require.NoError(t, err)
46 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
47 require.NoError(t, err)
48 require.Equal(t, "/status", requests[0].URL.Path)
49 require.Equal(t, "/acao", requests[1].URL.Path)
50 require.Equal(t, "fc610e7f98aa", requests[1].URL.Query().Get("mac"))
51 require.Equal(t, "21.5", requests[1].URL.Query().Get("stemp"))
52 require.Equal(t, "1", requests[2].URL.Query().Get("power"))
53 require.Len(t, requests[3].URL.Query(), 1)
54 require.Equal(t, http.MethodGet, requests[0].Method)
57 func TestHTTPClientRejectsInvalidAndIncompleteResponses(t *testing.T) {
58 testCases := []struct {
63 name: "missing flags do not become false observations",
64 body: `[{"mac":"fc610e7f98aa"}]`,
67 name: "invalid binary flag is rejected",
68 body: "[" + strings.Replace(validRecord, `"ativo":0`, `"ativo":2`, 1) + "]",
71 name: "invalid MAC is rejected",
72 body: "[" + strings.Replace(validRecord, "FC610E7F98AA", "no", 1) + "]",
75 name: "same MAC cannot identify two records",
76 body: "[" + validRecord + "," + validRecord + "]",
79 name: "trailing document is rejected",
83 name: "null does not imply an empty gateway",
87 name: "response size is bounded",
88 body: strings.Repeat(" ", gatewayResponseLimit+1),
91 name: "non array is rejected",
96 for _, tc := range testCases {
97 t.Run(tc.name, func(t *testing.T) {
98 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
99 return responseWith(tc.body), nil
101 require.NoError(t, err)
103 _, err = client.Status(t.Context())
104 require.Error(t, err)
105 require.NotContains(t, err.Error(), tc.body)
110 func TestHTTPClientAcceptsEmptyArraysAndExtraProperties(t *testing.T) {
111 for _, body := range []string{"[]", "[" + strings.Replace(validRecord, `"power":0`, `"extra":1,"power":0`, 1) + "]"} {
112 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
113 return responseWith(body), nil
115 require.NoError(t, err)
117 _, err = client.Status(t.Context())
118 require.NoError(t, err)
122 func TestHTTPClientRejectsUnsafeOriginsAndActions(t *testing.T) {
123 for _, origin := range []string{"https://example.com", "http://user@example.com", "http://example.com/", "http://example.com?token=secret", "http://example.com#x", "http://example.com?", "not a URL", "http://"} {
124 _, err := NewHTTPClient(origin, http.DefaultClient)
125 require.Error(t, err)
126 require.NotContains(t, err.Error(), origin)
129 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
130 t.Fatal("invalid action must not send a request")
132 return nil, errors.New("unexpected request")
134 require.NoError(t, err)
138 _, err = client.Action(t.Context(), "bad", Action{})
139 require.Error(t, err)
141 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{
143 TargetTemperature: &target,
145 require.Error(t, err)
148 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{TargetTemperature: &target})
149 require.Error(t, err)
152 func TestHTTPClientRejectsWrongActionReadback(t *testing.T) {
153 for _, body := range []string{"[]", "[" + validRecord + "," + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]", "[" + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]"} {
154 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
155 return responseWith(body), nil
157 require.NoError(t, err)
159 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
160 require.ErrorContains(t, err, "requested thermostat")
164 func TestHTTPClientBoundsTimeoutAndPreservesCancellation(t *testing.T) {
165 synctest.Test(t, func(t *testing.T) {
166 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
167 <-request.Context().Done()
169 return nil, request.Context().Err()
171 require.NoError(t, err)
174 _, err = client.Status(t.Context())
175 require.ErrorIs(t, err, context.DeadlineExceeded)
176 require.Equal(t, 5*time.Second, time.Since(start))
178 ctx, cancel := context.WithCancel(t.Context())
181 _, err = client.Status(ctx)
182 require.ErrorIs(t, err, context.Canceled)
186 func TestHTTPClientOmitsSensitiveTransportAndBodyErrors(t *testing.T) {
187 testCases := []struct {
189 response *http.Response
193 name: "transport URL stays private",
194 err: errors.New("http://example.com/acao?token=secret"),
197 name: "status body stays private",
198 response: &http.Response{
199 StatusCode: http.StatusForbidden,
200 Body: io.NopCloser(strings.NewReader("secret")),
204 name: "read error stays private",
205 response: &http.Response{
206 StatusCode: http.StatusOK,
207 Body: &brokenBody{readErr: errors.New("secret")},
211 name: "close error stays private",
212 response: &http.Response{
213 StatusCode: http.StatusOK,
214 Body: &brokenBody{closeErr: errors.New("secret")},
219 for _, tc := range testCases {
220 t.Run(tc.name, func(t *testing.T) {
221 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
222 return tc.response, tc.err
224 require.NoError(t, err)
226 _, err = client.Status(t.Context())
227 require.Error(t, err)
228 require.NotContains(t, err.Error(), "secret")
229 require.NotContains(t, err.Error(), "example.com")
234 func TestHTTPClientDoesNotFollowRedirects(t *testing.T) {
236 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
239 return &http.Response{
240 StatusCode: http.StatusFound,
241 Header: http.Header{"Location": {"http://other.example.com"}},
242 Body: io.NopCloser(strings.NewReader("")),
245 require.NoError(t, err)
247 _, err = client.Status(t.Context())
248 require.ErrorContains(t, err, "302")
249 require.Equal(t, 1, calls)
252 func TestHTTPClientKeepsIndependentFailureAlongsideCancellation(t *testing.T) {
253 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
254 return nil, errors.Join(context.Canceled, errors.New("secret"))
256 require.NoError(t, err)
258 _, err = client.Status(t.Context())
259 require.ErrorIs(t, err, context.Canceled)
260 require.ErrorContains(t, err, "gateway transport failed")
261 require.NotContains(t, err.Error(), "secret")
264 type roundTripFunc func(*http.Request) (*http.Response, error)
266 func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
270 type brokenBody struct {
275 func (b *brokenBody) Read(_ []byte) (int, error) {
276 if b.readErr != nil {
283 func (b *brokenBody) Close() error {
287 func responseWith(body string) *http.Response {
288 return &http.Response{
289 StatusCode: http.StatusOK,
290 Body: io.NopCloser(strings.NewReader(body)),
291 Header: http.Header{"Content-Length": {fmt.Sprint(len(body))}},