]> git.giacomolli.uk - onehouse/blob - driver/alamo/http_client_test.go
Poll and command shared Alamo thermostats
[onehouse] / driver / alamo / http_client_test.go
1 package alamo
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "math"
9 "net/http"
10 "strings"
11 "testing"
12 "testing/synctest"
13 "time"
14
15 "github.com/stretchr/testify/require"
16 )
17
18 func TestHTTPClientReadsAllRecordsAndOrderedActionFields(t *testing.T) {
19 var requests []*http.Request
20
21 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
22 requests = append(requests, request)
23
24 return responseWith("[" + validRecord + "]"), nil
25 })
26
27 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: transport})
28 require.NoError(t, err)
29
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)
36
37 target := 21.5
38 state, err := client.Action(t.Context(), "FC610E7F98AA", Action{TargetTemperature: &target})
39 require.NoError(t, err)
40 require.Equal(t, states[0], state)
41
42 power := true
43 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{Power: &power})
44 require.NoError(t, err)
45
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)
55 }
56
57 func TestHTTPClientRejectsInvalidAndIncompleteResponses(t *testing.T) {
58 testCases := []struct {
59 name string
60 body string
61 }{
62 {
63 name: "missing flags do not become false observations",
64 body: `[{"mac":"fc610e7f98aa"}]`,
65 },
66 {
67 name: "invalid binary flag is rejected",
68 body: "[" + strings.Replace(validRecord, `"ativo":0`, `"ativo":2`, 1) + "]",
69 },
70 {
71 name: "invalid MAC is rejected",
72 body: "[" + strings.Replace(validRecord, "FC610E7F98AA", "no", 1) + "]",
73 },
74 {
75 name: "same MAC cannot identify two records",
76 body: "[" + validRecord + "," + validRecord + "]",
77 },
78 {
79 name: "trailing document is rejected",
80 body: "[] []",
81 },
82 {
83 name: "null does not imply an empty gateway",
84 body: "null",
85 },
86 {
87 name: "response size is bounded",
88 body: strings.Repeat(" ", gatewayResponseLimit+1),
89 },
90 {
91 name: "non array is rejected",
92 body: "{}",
93 },
94 }
95
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
100 })})
101 require.NoError(t, err)
102
103 _, err = client.Status(t.Context())
104 require.Error(t, err)
105 require.NotContains(t, err.Error(), tc.body)
106 })
107 }
108 }
109
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
114 })})
115 require.NoError(t, err)
116
117 _, err = client.Status(t.Context())
118 require.NoError(t, err)
119 }
120 }
121
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)
127 }
128
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")
131
132 return nil, errors.New("unexpected request")
133 })})
134 require.NoError(t, err)
135
136 power := true
137 target := 20.0
138 _, err = client.Action(t.Context(), "bad", Action{})
139 require.Error(t, err)
140
141 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{
142 Power: &power,
143 TargetTemperature: &target,
144 })
145 require.Error(t, err)
146
147 target = math.Inf(1)
148 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{TargetTemperature: &target})
149 require.Error(t, err)
150 }
151
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
156 })})
157 require.NoError(t, err)
158
159 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
160 require.ErrorContains(t, err, "requested thermostat")
161 }
162 }
163
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()
168
169 return nil, request.Context().Err()
170 })})
171 require.NoError(t, err)
172
173 start := time.Now()
174 _, err = client.Status(t.Context())
175 require.ErrorIs(t, err, context.DeadlineExceeded)
176 require.Equal(t, 5*time.Second, time.Since(start))
177
178 ctx, cancel := context.WithCancel(t.Context())
179 cancel()
180
181 _, err = client.Status(ctx)
182 require.ErrorIs(t, err, context.Canceled)
183 })
184 }
185
186 func TestHTTPClientOmitsSensitiveTransportAndBodyErrors(t *testing.T) {
187 testCases := []struct {
188 name string
189 response *http.Response
190 err error
191 }{
192 {
193 name: "transport URL stays private",
194 err: errors.New("http://example.com/acao?token=secret"),
195 },
196 {
197 name: "status body stays private",
198 response: &http.Response{
199 StatusCode: http.StatusForbidden,
200 Body: io.NopCloser(strings.NewReader("secret")),
201 },
202 },
203 {
204 name: "read error stays private",
205 response: &http.Response{
206 StatusCode: http.StatusOK,
207 Body: &brokenBody{readErr: errors.New("secret")},
208 },
209 },
210 {
211 name: "close error stays private",
212 response: &http.Response{
213 StatusCode: http.StatusOK,
214 Body: &brokenBody{closeErr: errors.New("secret")},
215 },
216 },
217 }
218
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
223 })})
224 require.NoError(t, err)
225
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")
230 })
231 }
232 }
233
234 func TestHTTPClientDoesNotFollowRedirects(t *testing.T) {
235 calls := 0
236 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
237 calls++
238
239 return &http.Response{
240 StatusCode: http.StatusFound,
241 Header: http.Header{"Location": {"http://other.example.com"}},
242 Body: io.NopCloser(strings.NewReader("")),
243 }, nil
244 })})
245 require.NoError(t, err)
246
247 _, err = client.Status(t.Context())
248 require.ErrorContains(t, err, "302")
249 require.Equal(t, 1, calls)
250 }
251
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"))
255 })})
256 require.NoError(t, err)
257
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")
262 }
263
264 type roundTripFunc func(*http.Request) (*http.Response, error)
265
266 func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
267 return f(request)
268 }
269
270 type brokenBody struct {
271 readErr error
272 closeErr error
273 }
274
275 func (b *brokenBody) Read(_ []byte) (int, error) {
276 if b.readErr != nil {
277 return 0, b.readErr
278 }
279
280 return 0, io.EOF
281 }
282
283 func (b *brokenBody) Close() error {
284 return b.closeErr
285 }
286
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))}},
292 }
293 }