+package alamo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "strings"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+const validRecord = `{"mac":"FC610E7F98AA","power":0,"stemp":20,"temp":19.5,"sensor":23,"ativo":0,"bloqueio":1}`
+
+func TestHTTPClientReadsAllRecordsAndOrderedActionFields(t *testing.T) {
+ var requests []*http.Request
+
+ transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests = append(requests, request)
+
+ return responseWith("[" + validRecord + "]"), nil
+ })
+
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: transport})
+ require.NoError(t, err)
+
+ states, err := client.Status(t.Context())
+ require.NoError(t, err)
+ require.Len(t, states, 1)
+ require.Equal(t, "fc610e7f98aa", states[0].MAC)
+ require.InDelta(t, 19.5, states[0].CurrentTemperature, 0.001)
+ require.True(t, states[0].Locked)
+
+ target := 21.5
+ state, err := client.Action(t.Context(), "FC610E7F98AA", Action{TargetTemperature: &target})
+ require.NoError(t, err)
+ require.Equal(t, states[0], state)
+
+ power := true
+ _, err = client.Action(t.Context(), "fc610e7f98aa", Action{Power: &power})
+ require.NoError(t, err)
+
+ _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
+ require.NoError(t, err)
+ require.Equal(t, "/status", requests[0].URL.Path)
+ require.Equal(t, "/acao", requests[1].URL.Path)
+ require.Equal(t, "fc610e7f98aa", requests[1].URL.Query().Get("mac"))
+ require.Equal(t, "21.5", requests[1].URL.Query().Get("stemp"))
+ require.Equal(t, "1", requests[2].URL.Query().Get("power"))
+ require.Len(t, requests[3].URL.Query(), 1)
+ require.Equal(t, http.MethodGet, requests[0].Method)
+}
+
+func TestHTTPClientRejectsInvalidAndIncompleteResponses(t *testing.T) {
+ testCases := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "missing flags do not become false observations",
+ body: `[{"mac":"fc610e7f98aa"}]`,
+ },
+ {
+ name: "invalid binary flag is rejected",
+ body: "[" + strings.Replace(validRecord, `"ativo":0`, `"ativo":2`, 1) + "]",
+ },
+ {
+ name: "invalid MAC is rejected",
+ body: "[" + strings.Replace(validRecord, "FC610E7F98AA", "no", 1) + "]",
+ },
+ {
+ name: "same MAC cannot identify two records",
+ body: "[" + validRecord + "," + validRecord + "]",
+ },
+ {
+ name: "trailing document is rejected",
+ body: "[] []",
+ },
+ {
+ name: "null does not imply an empty gateway",
+ body: "null",
+ },
+ {
+ name: "response size is bounded",
+ body: strings.Repeat(" ", gatewayResponseLimit+1),
+ },
+ {
+ name: "non array is rejected",
+ body: "{}",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+ return responseWith(tc.body), nil
+ })})
+ require.NoError(t, err)
+
+ _, err = client.Status(t.Context())
+ require.Error(t, err)
+ require.NotContains(t, err.Error(), tc.body)
+ })
+ }
+}
+
+func TestHTTPClientAcceptsEmptyArraysAndExtraProperties(t *testing.T) {
+ for _, body := range []string{"[]", "[" + strings.Replace(validRecord, `"power":0`, `"extra":1,"power":0`, 1) + "]"} {
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+ return responseWith(body), nil
+ })})
+ require.NoError(t, err)
+
+ _, err = client.Status(t.Context())
+ require.NoError(t, err)
+ }
+}
+
+func TestHTTPClientRejectsUnsafeOriginsAndActions(t *testing.T) {
+ 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://"} {
+ _, err := NewHTTPClient(origin, http.DefaultClient)
+ require.Error(t, err)
+ require.NotContains(t, err.Error(), origin)
+ }
+
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+ t.Fatal("invalid action must not send a request")
+
+ return nil, errors.New("unexpected request")
+ })})
+ require.NoError(t, err)
+
+ power := true
+ target := 20.0
+ _, err = client.Action(t.Context(), "bad", Action{})
+ require.Error(t, err)
+
+ _, err = client.Action(t.Context(), "fc610e7f98aa", Action{
+ Power: &power,
+ TargetTemperature: &target,
+ })
+ require.Error(t, err)
+
+ target = math.Inf(1)
+ _, err = client.Action(t.Context(), "fc610e7f98aa", Action{TargetTemperature: &target})
+ require.Error(t, err)
+}
+
+func TestHTTPClientRejectsWrongActionReadback(t *testing.T) {
+ for _, body := range []string{"[]", "[" + validRecord + "," + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]", "[" + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]"} {
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+ return responseWith(body), nil
+ })})
+ require.NoError(t, err)
+
+ _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
+ require.ErrorContains(t, err, "requested thermostat")
+ }
+}
+
+func TestHTTPClientBoundsTimeoutAndPreservesCancellation(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ <-request.Context().Done()
+
+ return nil, request.Context().Err()
+ })})
+ require.NoError(t, err)
+
+ start := time.Now()
+ _, err = client.Status(t.Context())
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ require.Equal(t, 5*time.Second, time.Since(start))
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ _, err = client.Status(ctx)
+ require.ErrorIs(t, err, context.Canceled)
+ })
+}
+
+func TestHTTPClientOmitsSensitiveTransportAndBodyErrors(t *testing.T) {
+ testCases := []struct {
+ name string
+ response *http.Response
+ err error
+ }{
+ {
+ name: "transport URL stays private",
+ err: errors.New("http://example.com/acao?token=secret"),
+ },
+ {
+ name: "status body stays private",
+ response: &http.Response{
+ StatusCode: http.StatusForbidden,
+ Body: io.NopCloser(strings.NewReader("secret")),
+ },
+ },
+ {
+ name: "read error stays private",
+ response: &http.Response{
+ StatusCode: http.StatusOK,
+ Body: &brokenBody{readErr: errors.New("secret")},
+ },
+ },
+ {
+ name: "close error stays private",
+ response: &http.Response{
+ StatusCode: http.StatusOK,
+ Body: &brokenBody{closeErr: errors.New("secret")},
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+ return tc.response, tc.err
+ })})
+ require.NoError(t, err)
+
+ _, err = client.Status(t.Context())
+ require.Error(t, err)
+ require.NotContains(t, err.Error(), "secret")
+ require.NotContains(t, err.Error(), "example.com")
+ })
+ }
+}
+
+func TestHTTPClientDoesNotFollowRedirects(t *testing.T) {
+ calls := 0
+ client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+ calls++
+
+ return &http.Response{
+ StatusCode: http.StatusFound,
+ Header: http.Header{"Location": {"http://other.example.com"}},
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+ })})
+ require.NoError(t, err)
+
+ _, err = client.Status(t.Context())
+ require.ErrorContains(t, err, "302")
+ require.Equal(t, 1, calls)
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return f(request)
+}
+
+type brokenBody struct {
+ readErr error
+ closeErr error
+}
+
+func (b *brokenBody) Read(_ []byte) (int, error) {
+ if b.readErr != nil {
+ return 0, b.readErr
+ }
+
+ return 0, io.EOF
+}
+
+func (b *brokenBody) Close() error {
+ return b.closeErr
+}
+
+func responseWith(body string) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: http.Header{"Content-Length": {fmt.Sprint(len(body))}},
+ }
+}