]> git.giacomolli.uk - onehouse/blob - driver/alamo/http_client_test.go
Exchange bounded Alamo gateway requests
[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 const validRecord = `{"mac":"FC610E7F98AA","power":0,"stemp":20,"temp":19.5,"sensor":23,"ativo":0,"bloqueio":1}`
19
20 func TestHTTPClientReadsAllRecordsAndOrderedActionFields(t *testing.T) {
21 var requests []*http.Request
22
23 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
24 requests = append(requests, request)
25
26 return responseWith("[" + validRecord + "]"), nil
27 })
28
29 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: transport})
30 require.NoError(t, err)
31
32 states, err := client.Status(t.Context())
33 require.NoError(t, err)
34 require.Len(t, states, 1)
35 require.Equal(t, "fc610e7f98aa", states[0].MAC)
36 require.InDelta(t, 19.5, states[0].CurrentTemperature, 0.001)
37 require.True(t, states[0].Locked)
38
39 target := 21.5
40 state, err := client.Action(t.Context(), "FC610E7F98AA", Action{TargetTemperature: &target})
41 require.NoError(t, err)
42 require.Equal(t, states[0], state)
43
44 power := true
45 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{Power: &power})
46 require.NoError(t, err)
47
48 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
49 require.NoError(t, err)
50 require.Equal(t, "/status", requests[0].URL.Path)
51 require.Equal(t, "/acao", requests[1].URL.Path)
52 require.Equal(t, "fc610e7f98aa", requests[1].URL.Query().Get("mac"))
53 require.Equal(t, "21.5", requests[1].URL.Query().Get("stemp"))
54 require.Equal(t, "1", requests[2].URL.Query().Get("power"))
55 require.Len(t, requests[3].URL.Query(), 1)
56 require.Equal(t, http.MethodGet, requests[0].Method)
57 }
58
59 func TestHTTPClientRejectsInvalidAndIncompleteResponses(t *testing.T) {
60 testCases := []struct {
61 name string
62 body string
63 }{
64 {
65 name: "missing flags do not become false observations",
66 body: `[{"mac":"fc610e7f98aa"}]`,
67 },
68 {
69 name: "invalid binary flag is rejected",
70 body: "[" + strings.Replace(validRecord, `"ativo":0`, `"ativo":2`, 1) + "]",
71 },
72 {
73 name: "invalid MAC is rejected",
74 body: "[" + strings.Replace(validRecord, "FC610E7F98AA", "no", 1) + "]",
75 },
76 {
77 name: "same MAC cannot identify two records",
78 body: "[" + validRecord + "," + validRecord + "]",
79 },
80 {
81 name: "trailing document is rejected",
82 body: "[] []",
83 },
84 {
85 name: "null does not imply an empty gateway",
86 body: "null",
87 },
88 {
89 name: "response size is bounded",
90 body: strings.Repeat(" ", gatewayResponseLimit+1),
91 },
92 {
93 name: "non array is rejected",
94 body: "{}",
95 },
96 }
97
98 for _, tc := range testCases {
99 t.Run(tc.name, func(t *testing.T) {
100 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
101 return responseWith(tc.body), nil
102 })})
103 require.NoError(t, err)
104
105 _, err = client.Status(t.Context())
106 require.Error(t, err)
107 require.NotContains(t, err.Error(), tc.body)
108 })
109 }
110 }
111
112 func TestHTTPClientAcceptsEmptyArraysAndExtraProperties(t *testing.T) {
113 for _, body := range []string{"[]", "[" + strings.Replace(validRecord, `"power":0`, `"extra":1,"power":0`, 1) + "]"} {
114 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
115 return responseWith(body), nil
116 })})
117 require.NoError(t, err)
118
119 _, err = client.Status(t.Context())
120 require.NoError(t, err)
121 }
122 }
123
124 func TestHTTPClientRejectsUnsafeOriginsAndActions(t *testing.T) {
125 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://"} {
126 _, err := NewHTTPClient(origin, http.DefaultClient)
127 require.Error(t, err)
128 require.NotContains(t, err.Error(), origin)
129 }
130
131 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
132 t.Fatal("invalid action must not send a request")
133
134 return nil, errors.New("unexpected request")
135 })})
136 require.NoError(t, err)
137
138 power := true
139 target := 20.0
140 _, err = client.Action(t.Context(), "bad", Action{})
141 require.Error(t, err)
142
143 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{
144 Power: &power,
145 TargetTemperature: &target,
146 })
147 require.Error(t, err)
148
149 target = math.Inf(1)
150 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{TargetTemperature: &target})
151 require.Error(t, err)
152 }
153
154 func TestHTTPClientRejectsWrongActionReadback(t *testing.T) {
155 for _, body := range []string{"[]", "[" + validRecord + "," + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]", "[" + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]"} {
156 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
157 return responseWith(body), nil
158 })})
159 require.NoError(t, err)
160
161 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
162 require.ErrorContains(t, err, "requested thermostat")
163 }
164 }
165
166 func TestHTTPClientBoundsTimeoutAndPreservesCancellation(t *testing.T) {
167 synctest.Test(t, func(t *testing.T) {
168 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
169 <-request.Context().Done()
170
171 return nil, request.Context().Err()
172 })})
173 require.NoError(t, err)
174
175 start := time.Now()
176 _, err = client.Status(t.Context())
177 require.ErrorIs(t, err, context.DeadlineExceeded)
178 require.Equal(t, 5*time.Second, time.Since(start))
179
180 ctx, cancel := context.WithCancel(t.Context())
181 cancel()
182
183 _, err = client.Status(ctx)
184 require.ErrorIs(t, err, context.Canceled)
185 })
186 }
187
188 func TestHTTPClientOmitsSensitiveTransportAndBodyErrors(t *testing.T) {
189 testCases := []struct {
190 name string
191 response *http.Response
192 err error
193 }{
194 {
195 name: "transport URL stays private",
196 err: errors.New("http://example.com/acao?token=secret"),
197 },
198 {
199 name: "status body stays private",
200 response: &http.Response{
201 StatusCode: http.StatusForbidden,
202 Body: io.NopCloser(strings.NewReader("secret")),
203 },
204 },
205 {
206 name: "read error stays private",
207 response: &http.Response{
208 StatusCode: http.StatusOK,
209 Body: &brokenBody{readErr: errors.New("secret")},
210 },
211 },
212 {
213 name: "close error stays private",
214 response: &http.Response{
215 StatusCode: http.StatusOK,
216 Body: &brokenBody{closeErr: errors.New("secret")},
217 },
218 },
219 }
220
221 for _, tc := range testCases {
222 t.Run(tc.name, func(t *testing.T) {
223 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
224 return tc.response, tc.err
225 })})
226 require.NoError(t, err)
227
228 _, err = client.Status(t.Context())
229 require.Error(t, err)
230 require.NotContains(t, err.Error(), "secret")
231 require.NotContains(t, err.Error(), "example.com")
232 })
233 }
234 }
235
236 func TestHTTPClientDoesNotFollowRedirects(t *testing.T) {
237 calls := 0
238 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
239 calls++
240
241 return &http.Response{
242 StatusCode: http.StatusFound,
243 Header: http.Header{"Location": {"http://other.example.com"}},
244 Body: io.NopCloser(strings.NewReader("")),
245 }, nil
246 })})
247 require.NoError(t, err)
248
249 _, err = client.Status(t.Context())
250 require.ErrorContains(t, err, "302")
251 require.Equal(t, 1, calls)
252 }
253
254 type roundTripFunc func(*http.Request) (*http.Response, error)
255
256 func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
257 return f(request)
258 }
259
260 type brokenBody struct {
261 readErr error
262 closeErr error
263 }
264
265 func (b *brokenBody) Read(_ []byte) (int, error) {
266 if b.readErr != nil {
267 return 0, b.readErr
268 }
269
270 return 0, io.EOF
271 }
272
273 func (b *brokenBody) Close() error {
274 return b.closeErr
275 }
276
277 func responseWith(body string) *http.Response {
278 return &http.Response{
279 StatusCode: http.StatusOK,
280 Body: io.NopCloser(strings.NewReader(body)),
281 Header: http.Header{"Content-Length": {fmt.Sprint(len(body))}},
282 }
283 }