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))}}, } }