// Copyright (c) Mainflux // SPDX-License-Identifier: Apache-2.0 package http_test import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" "github.com/mainflux/mainflux" authmocks "github.com/mainflux/mainflux/auth/mocks" "github.com/mainflux/mainflux/internal/apiutil" "github.com/mainflux/mainflux/internal/testsutil" "github.com/mainflux/mainflux/logger" "github.com/mainflux/mainflux/twins" httpapi "github.com/mainflux/mainflux/twins/api/http" "github.com/mainflux/mainflux/twins/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) const ( twinName = "name" contentType = "application/json" email = "user@example.com" token = "token" wrongID = 0 maxNameSize = 1024 instanceID = "5de9b29a-feb9-11ed-be56-0242ac120002" ) var invalidName = strings.Repeat("m", maxNameSize+1) type twinReq struct { Name string `json:"name,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } type twinRes struct { Owner string `json:"owner"` ID string `json:"id"` Name string `json:"name,omitempty"` Revision int `json:"revision"` Metadata map[string]interface{} `json:"metadata,omitempty"` } type pageRes struct { Total uint64 `json:"total"` Offset uint64 `json:"offset"` Limit uint64 `json:"limit"` } type twinsPageRes struct { pageRes Twins []twinRes `json:"twins"` } type testRequest struct { client *http.Client method string url string contentType string token string body io.Reader } func (tr testRequest) make() (*http.Response, error) { req, err := http.NewRequest(tr.method, tr.url, tr.body) if err != nil { return nil, err } if tr.token != "" { req.Header.Set("Authorization", apiutil.BearerPrefix+tr.token) } if tr.contentType != "" { req.Header.Set("Content-Type", tr.contentType) } return tr.client.Do(req) } func newServer(svc twins.Service) *httptest.Server { logger := logger.NewMock() mux := httpapi.MakeHandler(svc, logger, instanceID) return httptest.NewServer(mux) } func toJSON(data interface{}) (string, error) { jsonData, err := json.Marshal(data) if err != nil { return "", err } return string(jsonData), nil } func TestAddTwin(t *testing.T) { svc, auth := mocks.NewService() ts := newServer(svc) defer ts.Close() tw := twinReq{} data, err := toJSON(tw) assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) tw.Name = invalidName invalidData, err := toJSON(tw) assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) cases := []struct { desc string req string contentType string auth string status int location string }{ { desc: "add valid twin", req: data, contentType: contentType, auth: token, status: http.StatusCreated, location: "/twins/123e4567-e89b-12d3-a456-000000000001", }, { desc: "add twin with empty JSON request", req: "{}", contentType: contentType, auth: token, status: http.StatusCreated, location: "/twins/123e4567-e89b-12d3-a456-000000000002", }, { desc: "add twin with invalid auth token", req: data, contentType: contentType, auth: authmocks.InvalidValue, status: http.StatusUnauthorized, location: "", }, { desc: "add twin with empty auth token", req: data, contentType: contentType, auth: "", status: http.StatusUnauthorized, location: "", }, { desc: "add twin with invalid request format", req: "}", contentType: contentType, auth: token, status: http.StatusBadRequest, location: "", }, { desc: "add twin with empty request", req: "", contentType: contentType, auth: token, status: http.StatusBadRequest, location: "", }, { desc: "add twin without content type", req: data, contentType: "", auth: token, status: http.StatusUnsupportedMediaType, location: "", }, { desc: "add twin with invalid name", req: invalidData, contentType: contentType, auth: token, status: http.StatusBadRequest, location: "", }, } for _, tc := range cases { repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: tc.auth}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) req := testRequest{ client: ts.Client(), method: http.MethodPost, url: fmt.Sprintf("%s/twins", ts.URL), contentType: tc.contentType, token: tc.auth, body: strings.NewReader(tc.req), } res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) location := res.Header.Get("Location") assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode)) assert.Equal(t, tc.location, location, fmt.Sprintf("%s: expected location %s got %s", tc.desc, tc.location, location)) repoCall.Unset() } } func TestUpdateTwin(t *testing.T) { svc, auth := mocks.NewService() ts := newServer(svc) defer ts.Close() twin := twins.Twin{} def := twins.Definition{} repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: token}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) stw, err := svc.AddTwin(context.Background(), token, twin, def) require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) repoCall.Unset() twin.Name = twinName data, err := toJSON(twin) assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) tw := twin tw.Name = invalidName invalidData, err := toJSON(tw) assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) cases := []struct { desc string req string id string contentType string auth string status int }{ { desc: "update existing twin", req: data, id: stw.ID, contentType: contentType, auth: token, status: http.StatusOK, }, { desc: "update twin with empty JSON request", req: "{}", id: stw.ID, contentType: contentType, auth: token, status: http.StatusBadRequest, }, { desc: "update non-existent twin", req: data, id: strconv.FormatUint(wrongID, 10), contentType: contentType, auth: token, status: http.StatusNotFound, }, { desc: "update twin with invalid token", req: data, id: stw.ID, contentType: contentType, auth: authmocks.InvalidValue, status: http.StatusUnauthorized, }, { desc: "update twin with empty token", req: data, id: stw.ID, contentType: contentType, auth: "", status: http.StatusUnauthorized, }, { desc: "update twin with invalid data format", req: "{", id: stw.ID, contentType: contentType, auth: token, status: http.StatusBadRequest, }, { desc: "update twin with empty request", req: "", id: stw.ID, contentType: contentType, auth: token, status: http.StatusBadRequest, }, { desc: "update twin without content type", req: data, id: stw.ID, contentType: "", auth: token, status: http.StatusUnsupportedMediaType, }, { desc: "update twin with invalid name", req: invalidData, contentType: contentType, auth: token, status: http.StatusBadRequest, }, } for _, tc := range cases { repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: tc.auth}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) req := testRequest{ client: ts.Client(), method: http.MethodPut, url: fmt.Sprintf("%s/twins/%s", ts.URL, tc.id), contentType: tc.contentType, token: tc.auth, body: strings.NewReader(tc.req), } res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode)) repoCall.Unset() } } func TestViewTwin(t *testing.T) { svc, auth := mocks.NewService() ts := newServer(svc) defer ts.Close() def := twins.Definition{} twin := twins.Twin{} repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: token}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) stw, err := svc.AddTwin(context.Background(), token, twin, def) require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) repoCall.Unset() twres := twinRes{ Owner: stw.Owner, Name: stw.Name, ID: stw.ID, Revision: stw.Revision, Metadata: stw.Metadata, } cases := []struct { desc string id string auth string status int res twinRes }{ { desc: "view existing twin", id: stw.ID, auth: token, status: http.StatusOK, res: twres, }, { desc: "view non-existent twin", id: strconv.FormatUint(wrongID, 10), auth: token, status: http.StatusNotFound, res: twinRes{}, }, { desc: "view twin by passing invalid token", id: stw.ID, auth: authmocks.InvalidValue, status: http.StatusUnauthorized, res: twinRes{}, }, { desc: "view twin by passing empty id", id: "", auth: token, status: http.StatusBadRequest, res: twinRes{}, }, { desc: "view twin by passing empty token", id: stw.ID, auth: "", status: http.StatusUnauthorized, res: twinRes{}, }, } for _, tc := range cases { repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: tc.auth}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) req := testRequest{ client: ts.Client(), method: http.MethodGet, url: fmt.Sprintf("%s/twins/%s", ts.URL, tc.id), token: tc.auth, } res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode)) var resData twinRes err = json.NewDecoder(res.Body).Decode(&resData) assert.Nil(t, err, fmt.Sprintf("%s: got unexpected error while decoding response body: %s\n", tc.desc, err)) assert.Equal(t, tc.res, resData, fmt.Sprintf("%s: expected body %v got %v", tc.desc, tc.res, resData)) repoCall.Unset() } } func TestListTwins(t *testing.T) { svc, auth := mocks.NewService() ts := newServer(svc) defer ts.Close() var data []twinRes userID := testsutil.GenerateUUID(t) for i := 0; i < 100; i++ { name := fmt.Sprintf("%s-%d", twinName, i) twin := twins.Twin{ Owner: email, Name: name, } repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: token}).Return(&mainflux.IdentityRes{Id: userID}, nil) tw, err := svc.AddTwin(context.Background(), token, twin, twins.Definition{}) assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) repoCall.Unset() twres := twinRes{ Owner: tw.Owner, ID: tw.ID, Name: tw.Name, Revision: tw.Revision, Metadata: tw.Metadata, } data = append(data, twres) } baseURL := fmt.Sprintf("%s/twins", ts.URL) queryFmt := "%s?offset=%d&limit=%d" cases := []struct { desc string auth string status int url string res []twinRes }{ { desc: "get a list of twins", auth: token, status: http.StatusOK, url: baseURL, res: data[0:10], }, { desc: "get a list of twins with invalid token", auth: authmocks.InvalidValue, status: http.StatusUnauthorized, url: fmt.Sprintf(queryFmt, baseURL, 0, 1), res: nil, }, { desc: "get a list of twins with empty token", auth: "", status: http.StatusUnauthorized, url: fmt.Sprintf(queryFmt, baseURL, 0, 1), res: nil, }, { desc: "get a list of twins with valid offset and limit", auth: token, status: http.StatusOK, url: fmt.Sprintf(queryFmt, baseURL, 25, 40), res: data[25:65], }, { desc: "get a list of twins with offset + limit > total", auth: token, status: http.StatusOK, url: fmt.Sprintf(queryFmt, baseURL, 91, 20), res: data[91:], }, { desc: "get a list of twins with negative offset", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf(queryFmt, baseURL, -1, 5), res: nil, }, { desc: "get a list of twins with negative limit", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf(queryFmt, baseURL, 1, -5), res: nil, }, { desc: "get a list of twins with zero limit", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf(queryFmt, baseURL, 1, 0), res: nil, }, { desc: "get a list of twins with limit greater than max", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf("%s?offset=%d&limit=%d", baseURL, 0, 110), res: nil, }, { desc: "get a list of twins with invalid offset", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf("%s%s", baseURL, "?offset=e&limit=5"), res: nil, }, { desc: "get a list of twins with invalid limit", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf("%s%s", baseURL, "?offset=5&limit=e"), res: nil, }, { desc: "get a list of twins without offset", auth: token, status: http.StatusOK, url: fmt.Sprintf("%s?limit=%d", baseURL, 5), res: data[0:5], }, { desc: "get a list of twins without limit", auth: token, status: http.StatusOK, url: fmt.Sprintf("%s?offset=%d", baseURL, 1), res: data[1:11], }, { desc: "get a list of twins with invalid number of parameters", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf("%s%s", baseURL, "?offset=4&limit=4&limit=5&offset=5"), res: nil, }, { desc: "get a list of twins with redundant query parameters", auth: token, status: http.StatusOK, url: fmt.Sprintf("%s?offset=%d&limit=%d&value=something", baseURL, 0, 5), res: data[0:5], }, { desc: "get a list of twins filtering with invalid name", auth: token, status: http.StatusBadRequest, url: fmt.Sprintf("%s?offset=%d&limit=%d&name=%s", baseURL, 0, 5, invalidName), res: nil, }, { desc: "get a list of twins filtering with valid name", auth: token, status: http.StatusOK, url: fmt.Sprintf("%s?offset=%d&limit=%d&name=%s", baseURL, 2, 1, twinName+"-2"), res: data[2:3], }, } for _, tc := range cases { repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: tc.auth}).Return(&mainflux.IdentityRes{Id: userID}, nil) req := testRequest{ client: ts.Client(), method: http.MethodGet, url: tc.url, token: tc.auth, } res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) var resData twinsPageRes if tc.res != nil { err = json.NewDecoder(res.Body).Decode(&resData) assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) } assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode)) assert.ElementsMatch(t, tc.res, resData.Twins, fmt.Sprintf("%s: got incorrect list of twins", tc.desc)) repoCall.Unset() } } func TestRemoveTwin(t *testing.T) { svc, auth := mocks.NewService() ts := newServer(svc) defer ts.Close() def := twins.Definition{} twin := twins.Twin{} repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: token}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) stw, err := svc.AddTwin(context.Background(), token, twin, def) require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err)) repoCall.Unset() cases := []struct { desc string id string auth string status int }{ { desc: "delete existing twin", id: stw.ID, auth: token, status: http.StatusNoContent, }, { desc: "delete non-existent twin", id: strconv.FormatUint(wrongID, 10), auth: token, status: http.StatusNoContent, }, { desc: "delete twin by passing empty id", id: "", auth: token, status: http.StatusBadRequest, }, { desc: "delete twin with invalid token", id: stw.ID, auth: authmocks.InvalidValue, status: http.StatusUnauthorized, }, { desc: "delete twin with empty token", id: stw.ID, auth: "", status: http.StatusUnauthorized, }, } for _, tc := range cases { repoCall := auth.On("Identify", mock.Anything, &mainflux.IdentityReq{Token: tc.auth}).Return(&mainflux.IdentityRes{Id: testsutil.GenerateUUID(t)}, nil) req := testRequest{ client: ts.Client(), method: http.MethodDelete, url: fmt.Sprintf("%s/twins/%s", ts.URL, tc.id), token: tc.auth, } res, err := req.make() assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err)) assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode)) repoCall.Unset() } }