15 "github.com/stretchr/testify/require"
18 const validRecord = `{"mac":"FC610E7F98AA","power":0,"stemp":20,"temp":19.5,"sensor":23,"ativo":0,"bloqueio":1}`
20 func TestHTTPClientReadsAllRecordsAndOrderedActionFields(t *testing.T) {
21 var requests []*http.Request
23 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
24 requests = append(requests, request)
26 return responseWith("[" + validRecord + "]"), nil
29 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: transport})
30 require.NoError(t, err)
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)
40 state, err := client.Action(t.Context(), "FC610E7F98AA", Action{TargetTemperature: &target})
41 require.NoError(t, err)
42 require.Equal(t, states[0], state)
45 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{Power: &power})
46 require.NoError(t, err)
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)
59 func TestHTTPClientRejectsInvalidAndIncompleteResponses(t *testing.T) {
60 testCases := []struct {
65 name: "missing flags do not become false observations",
66 body: `[{"mac":"fc610e7f98aa"}]`,
69 name: "invalid binary flag is rejected",
70 body: "[" + strings.Replace(validRecord, `"ativo":0`, `"ativo":2`, 1) + "]",
73 name: "invalid MAC is rejected",
74 body: "[" + strings.Replace(validRecord, "FC610E7F98AA", "no", 1) + "]",
77 name: "same MAC cannot identify two records",
78 body: "[" + validRecord + "," + validRecord + "]",
81 name: "trailing document is rejected",
85 name: "null does not imply an empty gateway",
89 name: "response size is bounded",
90 body: strings.Repeat(" ", gatewayResponseLimit+1),
93 name: "non array is rejected",
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
103 require.NoError(t, err)
105 _, err = client.Status(t.Context())
106 require.Error(t, err)
107 require.NotContains(t, err.Error(), tc.body)
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
117 require.NoError(t, err)
119 _, err = client.Status(t.Context())
120 require.NoError(t, err)
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)
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")
134 return nil, errors.New("unexpected request")
136 require.NoError(t, err)
140 _, err = client.Action(t.Context(), "bad", Action{})
141 require.Error(t, err)
143 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{
145 TargetTemperature: &target,
147 require.Error(t, err)
150 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{TargetTemperature: &target})
151 require.Error(t, err)
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
159 require.NoError(t, err)
161 _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
162 require.ErrorContains(t, err, "requested thermostat")
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()
171 return nil, request.Context().Err()
173 require.NoError(t, err)
176 _, err = client.Status(t.Context())
177 require.ErrorIs(t, err, context.DeadlineExceeded)
178 require.Equal(t, 5*time.Second, time.Since(start))
180 ctx, cancel := context.WithCancel(t.Context())
183 _, err = client.Status(ctx)
184 require.ErrorIs(t, err, context.Canceled)
188 func TestHTTPClientOmitsSensitiveTransportAndBodyErrors(t *testing.T) {
189 testCases := []struct {
191 response *http.Response
195 name: "transport URL stays private",
196 err: errors.New("http://example.com/acao?token=secret"),
199 name: "status body stays private",
200 response: &http.Response{
201 StatusCode: http.StatusForbidden,
202 Body: io.NopCloser(strings.NewReader("secret")),
206 name: "read error stays private",
207 response: &http.Response{
208 StatusCode: http.StatusOK,
209 Body: &brokenBody{readErr: errors.New("secret")},
213 name: "close error stays private",
214 response: &http.Response{
215 StatusCode: http.StatusOK,
216 Body: &brokenBody{closeErr: errors.New("secret")},
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
226 require.NoError(t, err)
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")
236 func TestHTTPClientDoesNotFollowRedirects(t *testing.T) {
238 client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
241 return &http.Response{
242 StatusCode: http.StatusFound,
243 Header: http.Header{"Location": {"http://other.example.com"}},
244 Body: io.NopCloser(strings.NewReader("")),
247 require.NoError(t, err)
249 _, err = client.Status(t.Context())
250 require.ErrorContains(t, err, "302")
251 require.Equal(t, 1, calls)
254 type roundTripFunc func(*http.Request) (*http.Response, error)
256 func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
260 type brokenBody struct {
265 func (b *brokenBody) Read(_ []byte) (int, error) {
266 if b.readErr != nil {
273 func (b *brokenBody) Close() error {
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))}},