From c398908d50d7a1fa04e4111e90ba6ba3b457430d Mon Sep 17 00:00:00 2001 From: Steve Munene <61874077+nyagamunene@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:12:43 +0300 Subject: [PATCH] MG-2330 - Fix non-admin users search with identity (#2331) Signed-off-by: nyagamunene --- api/openapi/users.yml | 26 ++++++++ cli/users.go | 47 +++++++++++++- internal/api/common.go | 4 +- pkg/apiutil/errors.go | 6 ++ pkg/sdk/go/sdk.go | 12 ++++ pkg/sdk/go/users.go | 19 ++++++ pkg/sdk/go/users_test.go | 121 +++++++++++++++++++++++++++++++++++++ pkg/sdk/mocks/sdk.go | 30 +++++++++ users/api/clients.go | 55 +++++++++++++++++ users/api/endpoint_test.go | 119 ++++++++++++++++++++++++++++++++++++ users/api/endpoints.go | 36 +++++++++++ users/api/logging.go | 21 +++++++ users/api/metrics.go | 9 +++ users/api/requests.go | 22 +++++++ users/api/requests_test.go | 36 +++++++++++ users/clients.go | 3 + users/events/events.go | 26 ++++++++ users/events/streams.go | 16 +++++ users/mocks/service.go | 28 +++++++++ users/service.go | 36 +++++++---- users/service_test.go | 112 +++++++++++++++++++++++++--------- users/tracing/tracing.go | 8 +++ 22 files changed, 752 insertions(+), 40 deletions(-) diff --git a/api/openapi/users.yml b/api/openapi/users.yml index c456929ed7..d2407ef964 100644 --- a/api/openapi/users.yml +++ b/api/openapi/users.yml @@ -397,6 +397,32 @@ paths: "500": $ref: "#/components/responses/ServiceError" + /users/search: + get: + operationId: searchUsers + summary: Search users + description: | + Search users by name and identity. + tags: + - Users + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/UserName" + - $ref: "#/components/parameters/UserIdentity" + - $ref: "#/components/parameters/UserID" + security: + - bearerAuth: [] + responses: + "200": + $ref: "#/components/responses/UserPageRes" + "400": + description: Failed due to malformed query parameters. + "401": + description: Missing or invalid access token provided. + "500": + $ref: "#/components/responses/ServiceError" + /password/reset-request: post: operationId: requestPasswordReset diff --git a/cli/users.go b/cli/users.go index 01283fb29c..70922a3dcf 100644 --- a/cli/users.go +++ b/cli/users.go @@ -5,6 +5,9 @@ package cli import ( "encoding/json" + "fmt" + "net/url" + "strconv" mgclients "github.com/absmach/magistrala/pkg/clients" mgxsdk "github.com/absmach/magistrala/pkg/sdk/go" @@ -463,12 +466,54 @@ var cmdUsers = []cobra.Command{ logJSONCmd(*cmd, users) }, }, + + { + Use: "search ", + Short: "Search users", + Long: "Search users by query\n" + + "Usage:\n" + + "\tmagistrala-cli users search \n", + Run: func(cmd *cobra.Command, args []string) { + if len(args) != 2 { + logUsageCmd(*cmd, cmd.Use) + return + } + + values, err := url.ParseQuery(args[0]) + if err != nil { + logErrorCmd(*cmd, fmt.Errorf("failed to parse query: %s", err)) + } + + pm := mgxsdk.PageMetadata{ + Offset: Offset, + Limit: Limit, + Name: values.Get("name"), + ID: values.Get("id"), + } + + if off, err := strconv.Atoi(values.Get("offset")); err == nil { + pm.Offset = uint64(off) + } + + if lim, err := strconv.Atoi(values.Get("limit")); err == nil { + pm.Limit = uint64(lim) + } + + users, err := sdk.SearchUsers(pm, args[1]) + if err != nil { + logErrorCmd(*cmd, err) + return + } + + logJSONCmd(*cmd, users) + }, + }, } // NewUsersCmd returns users command. func NewUsersCmd() *cobra.Command { cmd := cobra.Command{ - Use: "users [create | get | update | token | password | enable | disable | delete | channels | things | groups]", + Use: "users [create | get | update | token | password | enable | disable | delete | channels | things | groups | search]", Short: "Users management", Long: `Users management: create accounts and tokens"`, } diff --git a/internal/api/common.go b/internal/api/common.go index 68388e0254..2d7de5cbf3 100644 --- a/internal/api/common.go +++ b/internal/api/common.go @@ -160,7 +160,9 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) { errors.Contains(err, apiutil.ErrInvalidEntityType), errors.Contains(err, apiutil.ErrMissingEntityType), errors.Contains(err, apiutil.ErrInvalidTimeFormat), - errors.Contains(err, svcerr.ErrSearch): + errors.Contains(err, svcerr.ErrSearch), + errors.Contains(err, apiutil.ErrEmptySearchQuery), + errors.Contains(err, apiutil.ErrLenSearchQuery): err = unwrap(err) w.WriteHeader(http.StatusBadRequest) diff --git a/pkg/apiutil/errors.go b/pkg/apiutil/errors.go index 833f8ecbd8..cceba23528 100644 --- a/pkg/apiutil/errors.go +++ b/pkg/apiutil/errors.go @@ -179,4 +179,10 @@ var ( // ErrInvalidTimeFormat indicates invalid time format i.e not unix time. ErrInvalidTimeFormat = errors.New("invalid time format use unix time") + + // ErrEmptySearchQuery indicates search query should not be empty. + ErrEmptySearchQuery = errors.New("search query must not be empty") + + // ErrLenSearchQuery indicates search query length. + ErrLenSearchQuery = errors.New("search query must be at least 3 characters") ) diff --git a/pkg/sdk/go/sdk.go b/pkg/sdk/go/sdk.go index 8a2565a9ef..7ea46c298b 100644 --- a/pkg/sdk/go/sdk.go +++ b/pkg/sdk/go/sdk.go @@ -336,6 +336,18 @@ type SDK interface { // fmt.Println(things) ListUserThings(userID string, pm PageMetadata, token string) (ThingsPage, errors.SDKError) + // SeachUsers filters users and returns a page result. + // + // example: + // pm := sdk.PageMetadata{ + // Offset: 0, + // Limit: 10, + // Name: "John Doe", + // } + // users, _ := sdk.SearchUsers(pm, "token") + // fmt.Println(users) + SearchUsers(pm PageMetadata, token string) (UsersPage, errors.SDKError) + // CreateThing registers new thing and returns its id. // // example: diff --git a/pkg/sdk/go/users.go b/pkg/sdk/go/users.go index 994ff52317..a20bfb7504 100644 --- a/pkg/sdk/go/users.go +++ b/pkg/sdk/go/users.go @@ -328,6 +328,25 @@ func (sdk mgSDK) ListUserThings(userID string, pm PageMetadata, token string) (T return tp, nil } +func (sdk mgSDK) SearchUsers(pm PageMetadata, token string) (UsersPage, errors.SDKError) { + url, err := sdk.withQueryParams(sdk.usersURL, fmt.Sprintf("%s/search", usersEndpoint), pm) + if err != nil { + return UsersPage{}, errors.NewSDKError(err) + } + + _, body, sdkerr := sdk.processRequest(http.MethodGet, url, token, nil, nil, http.StatusOK) + if sdkerr != nil { + return UsersPage{}, sdkerr + } + + var cp UsersPage + if err := json.Unmarshal(body, &cp); err != nil { + return UsersPage{}, errors.NewSDKError(err) + } + + return cp, nil +} + func (sdk mgSDK) EnableUser(id, token string) (User, errors.SDKError) { return sdk.changeClientStatus(token, id, enableEndpoint) } diff --git a/pkg/sdk/go/users_test.go b/pkg/sdk/go/users_test.go index fa5fbd3f26..2365b56108 100644 --- a/pkg/sdk/go/users_test.go +++ b/pkg/sdk/go/users_test.go @@ -542,6 +542,127 @@ func TestListUsers(t *testing.T) { } } +func TestSearchClients(t *testing.T) { + ts, svc := setupUsers() + defer ts.Close() + + var cls []sdk.User + conf := sdk.Config{ + UsersURL: ts.URL, + } + mgsdk := sdk.NewSDK(conf) + + for i := 10; i < 100; i++ { + cl := sdk.User{ + ID: generateUUID(t), + Name: fmt.Sprintf("client_%d", i), + Credentials: sdk.Credentials{ + Identity: fmt.Sprintf("identity_%d", i), + Secret: fmt.Sprintf("password_%d", i), + }, + Metadata: sdk.Metadata{"name": fmt.Sprintf("client_%d", i)}, + Status: mgclients.EnabledStatus.String(), + } + if i == 50 { + cl.Status = mgclients.DisabledStatus.String() + cl.Tags = []string{"tag1", "tag2"} + } + cls = append(cls, cl) + } + + cases := []struct { + desc string + token string + page sdk.PageMetadata + response []sdk.User + searchreturn mgclients.ClientsPage + err errors.SDKError + identifyErr error + }{ + { + desc: "search for users", + token: validToken, + err: nil, + page: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + Name: "client_10", + }, + response: []sdk.User{cls[10]}, + searchreturn: mgclients.ClientsPage{ + Clients: []mgclients.Client{convertClient(cls[10])}, + Page: mgclients.Page{ + Total: 1, + Offset: offset, + Limit: limit, + }, + }, + }, + { + desc: "search for users with invalid token", + token: invalidToken, + page: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + Name: "client_10", + }, + err: errors.NewSDKErrorWithStatus(svcerr.ErrAuthentication, http.StatusUnauthorized), + response: nil, + identifyErr: svcerr.ErrAuthentication, + }, + { + desc: "search for users with empty token", + token: "", + page: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + Name: "client_10", + }, + err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, apiutil.ErrBearerToken), http.StatusUnauthorized), + response: nil, + identifyErr: svcerr.ErrAuthentication, + }, + { + desc: "search for users with empty query", + token: validToken, + page: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + Name: "", + }, + err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, apiutil.ErrEmptySearchQuery), http.StatusBadRequest), + }, + { + desc: "search for users with invalid length of query", + token: validToken, + page: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + Name: "a", + }, + err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrLenSearchQuery, apiutil.ErrValidation), http.StatusBadRequest), + }, + { + desc: "search for users with invalid limit", + token: validToken, + page: sdk.PageMetadata{ + Offset: offset, + Limit: 0, + Name: "client_10", + }, + err: errors.NewSDKErrorWithStatus(errors.Wrap(apiutil.ErrValidation, apiutil.ErrLimitSize), http.StatusBadRequest), + }, + } + + for _, tc := range cases { + repoCall := svc.On("SearchUsers", mock.Anything, mock.Anything, mock.Anything).Return(tc.searchreturn, tc.err) + page, err := mgsdk.SearchUsers(tc.page, tc.token) + assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected error %s, got %s", tc.desc, tc.err, err)) + assert.Equal(t, tc.response, page.Users, fmt.Sprintf("%s: expected %v got %v\n", tc.desc, tc.response, page)) + repoCall.Unset() + } +} + func TestViewUser(t *testing.T) { ts, svc := setupUsers() defer ts.Close() diff --git a/pkg/sdk/mocks/sdk.go b/pkg/sdk/mocks/sdk.go index e384fe78df..535f44a0d4 100644 --- a/pkg/sdk/mocks/sdk.go +++ b/pkg/sdk/mocks/sdk.go @@ -2050,6 +2050,36 @@ func (_m *SDK) RevokeCert(thingID string, token string) (time.Time, errors.SDKEr return r0, r1 } +// SearchUsers provides a mock function with given fields: pm, token +func (_m *SDK) SearchUsers(pm sdk.PageMetadata, token string) (sdk.UsersPage, errors.SDKError) { + ret := _m.Called(pm, token) + + if len(ret) == 0 { + panic("no return value specified for SearchUsers") + } + + var r0 sdk.UsersPage + var r1 errors.SDKError + if rf, ok := ret.Get(0).(func(sdk.PageMetadata, string) (sdk.UsersPage, errors.SDKError)); ok { + return rf(pm, token) + } + if rf, ok := ret.Get(0).(func(sdk.PageMetadata, string) sdk.UsersPage); ok { + r0 = rf(pm, token) + } else { + r0 = ret.Get(0).(sdk.UsersPage) + } + + if rf, ok := ret.Get(1).(func(sdk.PageMetadata, string) errors.SDKError); ok { + r1 = rf(pm, token) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(errors.SDKError) + } + } + + return r0, r1 +} + // SendInvitation provides a mock function with given fields: invitation, token func (_m *SDK) SendInvitation(invitation sdk.Invitation, token string) error { ret := _m.Called(invitation, token) diff --git a/users/api/clients.go b/users/api/clients.go index 2ca18bea45..48a454c6d3 100644 --- a/users/api/clients.go +++ b/users/api/clients.go @@ -62,6 +62,13 @@ func clientsHandler(svc users.Service, r *chi.Mux, logger *slog.Logger, pr *rege opts..., ), "list_clients").ServeHTTP) + r.Get("/search", otelhttp.NewHandler(kithttp.NewServer( + searchClientsEndpoint(svc), + decodeSearchClients, + api.EncodeResponse, + opts..., + ), "search_clients").ServeHTTP) + r.Patch("/secret", otelhttp.NewHandler(kithttp.NewServer( updateClientSecretEndpoint(svc), decodeUpdateClientSecret, @@ -272,6 +279,54 @@ func decodeListClients(_ context.Context, r *http.Request) (interface{}, error) return req, nil } +func decodeSearchClients(_ context.Context, r *http.Request) (interface{}, error) { + o, err := apiutil.ReadNumQuery[uint64](r, api.OffsetKey, api.DefOffset) + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + l, err := apiutil.ReadNumQuery[uint64](r, api.LimitKey, api.DefLimit) + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + n, err := apiutil.ReadStringQuery(r, api.NameKey, "") + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + id, err := apiutil.ReadStringQuery(r, api.IDOrder, "") + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + order, err := apiutil.ReadStringQuery(r, api.OrderKey, api.DefOrder) + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + dir, err := apiutil.ReadStringQuery(r, api.DirKey, api.DefDir) + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + + req := searchClientsReq{ + token: apiutil.ExtractBearerToken(r), + Offset: o, + Limit: l, + Name: n, + Id: id, + Order: order, + Dir: dir, + } + + for _, field := range []string{req.Name, req.Id} { + if field != "" && len(field) < 3 { + req = searchClientsReq{ + token: apiutil.ExtractBearerToken(r), + } + return req, errors.Wrap(apiutil.ErrLenSearchQuery, apiutil.ErrValidation) + } + } + + return req, nil +} + func decodeUpdateClient(_ context.Context, r *http.Request) (interface{}, error) { if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) { return nil, errors.Wrap(apiutil.ErrValidation, apiutil.ErrUnsupportedContentType) diff --git a/users/api/endpoint_test.go b/users/api/endpoint_test.go index 991223ccf1..acfcae3836 100644 --- a/users/api/endpoint_test.go +++ b/users/api/endpoint_test.go @@ -656,6 +656,125 @@ func TestListClients(t *testing.T) { } } +func TestSearchUsers(t *testing.T) { + us, svc, _ := newUsersServer() + defer us.Close() + + cases := []struct { + desc string + token string + page mgclients.Page + status int + query string + listUsersResponse mgclients.ClientsPage + err error + }{ + { + desc: "search users with valid token", + token: validToken, + status: http.StatusOK, + query: "name=clientname", + listUsersResponse: mgclients.ClientsPage{ + Page: mgclients.Page{ + Total: 1, + }, + Clients: []mgclients.Client{client}, + }, + err: nil, + }, + { + desc: "search users with empty token", + token: "", + query: "name=clientname", + status: http.StatusUnauthorized, + err: apiutil.ErrBearerToken, + }, + { + desc: "search users with invalid token", + token: inValidToken, + query: "name=clientname", + status: http.StatusUnauthorized, + err: svcerr.ErrAuthentication, + }, + { + desc: "search users with offset", + token: validToken, + listUsersResponse: mgclients.ClientsPage{ + Page: mgclients.Page{ + Offset: 1, + Total: 1, + }, + Clients: []mgclients.Client{client}, + }, + query: "name=clientname&offset=1", + status: http.StatusOK, + err: nil, + }, + { + desc: "search users with invalid offset", + token: validToken, + query: "name=clientname&offset=invalid", + status: http.StatusBadRequest, + err: apiutil.ErrValidation, + }, + { + desc: "search users with limit", + token: validToken, + listUsersResponse: mgclients.ClientsPage{ + Page: mgclients.Page{ + Limit: 1, + Total: 1, + }, + Clients: []mgclients.Client{client}, + }, + query: "name=clientname&limit=1", + status: http.StatusOK, + err: nil, + }, + { + desc: "search users with invalid limit", + token: validToken, + query: "name=clientname&limit=invalid", + status: http.StatusBadRequest, + err: apiutil.ErrValidation, + }, + { + desc: "search users with empty query", + token: validToken, + query: "", + status: http.StatusBadRequest, + err: apiutil.ErrEmptySearchQuery, + }, + { + desc: "search users with invalid length of query", + token: validToken, + query: "name=a", + status: http.StatusBadRequest, + err: apiutil.ErrLenSearchQuery, + }, + } + + for _, tc := range cases { + req := testRequest{ + client: us.Client(), + method: http.MethodGet, + url: fmt.Sprintf("%s/users/search?", us.URL) + tc.query, + token: tc.token, + } + + svcCall := svc.On("SearchUsers", mock.Anything, tc.token, mock.Anything).Return( + mgclients.ClientsPage{ + Page: tc.listUsersResponse.Page, + Clients: tc.listUsersResponse.Clients, + }, + tc.err) + 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)) + svcCall.Unset() + } +} + func TestUpdateClient(t *testing.T) { us, svc, _ := newUsersServer() defer us.Close() diff --git a/users/api/endpoints.go b/users/api/endpoints.go index 4f6dfa0f7c..4185bace1f 100644 --- a/users/api/endpoints.go +++ b/users/api/endpoints.go @@ -104,6 +104,42 @@ func listClientsEndpoint(svc users.Service) endpoint.Endpoint { } } +func searchClientsEndpoint(svc users.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(searchClientsReq) + if err := req.validate(); err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + + pm := mgclients.Page{ + Offset: req.Offset, + Limit: req.Limit, + Name: req.Name, + Id: req.Id, + Order: req.Order, + Dir: req.Dir, + } + page, err := svc.SearchUsers(ctx, req.token, pm) + if err != nil { + return nil, err + } + + res := clientsPageRes{ + pageRes: pageRes{ + Total: page.Total, + Offset: page.Offset, + Limit: page.Limit, + }, + Clients: []viewClientRes{}, + } + for _, client := range page.Clients { + res.Clients = append(res.Clients, viewClientRes{Client: client}) + } + + return res, nil + } +} + func listMembersByGroupEndpoint(svc users.Service) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { req := request.(listMembersByObjectReq) diff --git a/users/api/logging.go b/users/api/logging.go index 7ed01bcbe7..57f5960315 100644 --- a/users/api/logging.go +++ b/users/api/logging.go @@ -152,6 +152,27 @@ func (lm *loggingMiddleware) ListClients(ctx context.Context, token string, pm m return lm.svc.ListClients(ctx, token, pm) } +// SearchUsers logs the search_users request. It logs the page metadata and the time it took to complete the request. +func (lm *loggingMiddleware) SearchUsers(ctx context.Context, token string, cp mgclients.Page) (mp mgclients.ClientsPage, err error) { + defer func(begin time.Time) { + args := []any{ + slog.String("duration", time.Since(begin).String()), + slog.Group("page", + slog.Uint64("limit", cp.Limit), + slog.Uint64("offset", cp.Offset), + slog.Uint64("total", mp.Total), + ), + } + if err != nil { + args = append(args, slog.Any("error", err)) + lm.logger.Warn("Search clients failed to complete successfully", args...) + return + } + lm.logger.Info("Search clients completed successfully", args...) + }(time.Now()) + return lm.svc.SearchUsers(ctx, token, cp) +} + // UpdateClient logs the update_client request. It logs the client id and the time it took to complete the request. // If the request fails, it logs the error. func (lm *loggingMiddleware) UpdateClient(ctx context.Context, token string, client mgclients.Client) (c mgclients.Client, err error) { diff --git a/users/api/metrics.go b/users/api/metrics.go index ccf231adda..0f523ba9ec 100644 --- a/users/api/metrics.go +++ b/users/api/metrics.go @@ -84,6 +84,15 @@ func (ms *metricsMiddleware) ListClients(ctx context.Context, token string, pm m return ms.svc.ListClients(ctx, token, pm) } +// SearchUsers instruments SearchClients method with metrics. +func (ms *metricsMiddleware) SearchUsers(ctx context.Context, token string, pm mgclients.Page) (mp mgclients.ClientsPage, err error) { + defer func(begin time.Time) { + ms.counter.With("method", "search_users").Add(1) + ms.latency.With("method", "search_users").Observe(time.Since(begin).Seconds()) + }(time.Now()) + return ms.svc.SearchUsers(ctx, token, pm) +} + // UpdateClient instruments UpdateClient method with metrics. func (ms *metricsMiddleware) UpdateClient(ctx context.Context, token string, client mgclients.Client) (mgclients.Client, error) { defer func(begin time.Time) { diff --git a/users/api/requests.go b/users/api/requests.go index df93411e12..8114d67c99 100644 --- a/users/api/requests.go +++ b/users/api/requests.go @@ -89,6 +89,28 @@ func (req listClientsReq) validate() error { return nil } +type searchClientsReq struct { + token string + Offset uint64 + Limit uint64 + Name string + Id string + Order string + Dir string +} + +func (req searchClientsReq) validate() error { + if req.token == "" { + return apiutil.ErrBearerToken + } + + if req.Name == "" && req.Id == "" { + return apiutil.ErrEmptySearchQuery + } + + return nil +} + type listMembersByObjectReq struct { mgclients.Page token string diff --git a/users/api/requests_test.go b/users/api/requests_test.go index 9e5601ce1f..f238225fcd 100644 --- a/users/api/requests_test.go +++ b/users/api/requests_test.go @@ -237,6 +237,42 @@ func TestListClientsReqValidate(t *testing.T) { } } +func TestSearchClientsReqValidate(t *testing.T) { + cases := []struct { + desc string + req searchClientsReq + err error + }{ + { + desc: "valid request", + req: searchClientsReq{ + token: valid, + Name: name, + }, + err: nil, + }, + { + desc: "empty token", + req: searchClientsReq{ + token: "", + Name: name, + }, + err: apiutil.ErrBearerToken, + }, + { + desc: "empty query", + req: searchClientsReq{ + token: valid, + }, + err: apiutil.ErrEmptySearchQuery, + }, + } + for _, c := range cases { + err := c.req.validate() + assert.Equal(t, c.err, err) + } +} + func TestListMembersByObjectReqValidate(t *testing.T) { cases := []struct { desc string diff --git a/users/clients.go b/users/clients.go index e40c4f226d..078f8ce4fb 100644 --- a/users/clients.go +++ b/users/clients.go @@ -31,6 +31,9 @@ type Service interface { // ListMembers retrieves everything that is assigned to a group/thing identified by objectID. ListMembers(ctx context.Context, token, objectKind, objectID string, pm clients.Page) (clients.MembersPage, error) + // SearchClients searches for users with provided filters for a valid auth token. + SearchUsers(ctx context.Context, token string, pm clients.Page) (clients.ClientsPage, error) + // UpdateClient updates the client's name and metadata. UpdateClient(ctx context.Context, token string, client clients.Client) (clients.Client, error) diff --git a/users/events/events.go b/users/events/events.go index dda7a669a6..d120bc5902 100644 --- a/users/events/events.go +++ b/users/events/events.go @@ -18,6 +18,7 @@ const ( clientView = clientPrefix + "view" profileView = clientPrefix + "view_profile" clientList = clientPrefix + "list" + clientSearch = clientPrefix + "search" clientListByGroup = clientPrefix + "list_by_group" clientIdentify = clientPrefix + "identify" generateResetToken = clientPrefix + "generate_reset_token" @@ -37,6 +38,7 @@ var ( _ events.Event = (*viewProfileEvent)(nil) _ events.Event = (*listClientEvent)(nil) _ events.Event = (*listClientByGroupEvent)(nil) + _ events.Event = (*searchClientEvent)(nil) _ events.Event = (*identifyClientEvent)(nil) _ events.Event = (*generateResetTokenEvent)(nil) _ events.Event = (*issueTokenEvent)(nil) @@ -307,6 +309,30 @@ func (lcge listClientByGroupEvent) Encode() (map[string]interface{}, error) { return val, nil } +type searchClientEvent struct { + mgclients.Page +} + +func (sce searchClientEvent) Encode() (map[string]interface{}, error) { + val := map[string]interface{}{ + "operation": clientSearch, + "total": sce.Total, + "offset": sce.Offset, + "limit": sce.Limit, + } + if sce.Name != "" { + val["name"] = sce.Name + } + if sce.Identity != "" { + val["identity"] = sce.Identity + } + if sce.Id != "" { + val["id"] = sce.Id + } + + return val, nil +} + type identifyClientEvent struct { userID string } diff --git a/users/events/streams.go b/users/events/streams.go index 5563d89478..a8cd9d7193 100644 --- a/users/events/streams.go +++ b/users/events/streams.go @@ -160,6 +160,22 @@ func (es *eventStore) ListClients(ctx context.Context, token string, pm mgclient return cp, nil } +func (es *eventStore) SearchUsers(ctx context.Context, token string, pm mgclients.Page) (mgclients.ClientsPage, error) { + cp, err := es.svc.SearchUsers(ctx, token, pm) + if err != nil { + return cp, err + } + event := searchClientEvent{ + pm, + } + + if err := es.Publish(ctx, event); err != nil { + return cp, err + } + + return cp, nil +} + func (es *eventStore) ListMembers(ctx context.Context, token, objectKind, objectID string, pm mgclients.Page) (mgclients.MembersPage, error) { mp, err := es.svc.ListMembers(ctx, token, objectKind, objectID, pm) if err != nil { diff --git a/users/mocks/service.go b/users/mocks/service.go index cd769feeca..f143cef06c 100644 --- a/users/mocks/service.go +++ b/users/mocks/service.go @@ -331,6 +331,34 @@ func (_m *Service) ResetSecret(ctx context.Context, resetToken string, secret st return r0 } +// SearchUsers provides a mock function with given fields: ctx, token, pm +func (_m *Service) SearchUsers(ctx context.Context, token string, pm clients.Page) (clients.ClientsPage, error) { + ret := _m.Called(ctx, token, pm) + + if len(ret) == 0 { + panic("no return value specified for SearchUsers") + } + + var r0 clients.ClientsPage + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, clients.Page) (clients.ClientsPage, error)); ok { + return rf(ctx, token, pm) + } + if rf, ok := ret.Get(0).(func(context.Context, string, clients.Page) clients.ClientsPage); ok { + r0 = rf(ctx, token, pm) + } else { + r0 = ret.Get(0).(clients.ClientsPage) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, clients.Page) error); ok { + r1 = rf(ctx, token, pm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // SendPasswordReset provides a mock function with given fields: ctx, host, email, user, token func (_m *Service) SendPasswordReset(ctx context.Context, host string, email string, user string, token string) error { ret := _m.Called(ctx, host, email, user, token) diff --git a/users/service.go b/users/service.go index 07ec3b972f..dfc1ebeb8c 100644 --- a/users/service.go +++ b/users/service.go @@ -180,24 +180,38 @@ func (svc service) ListClients(ctx context.Context, token string, pm mgclients.P if err != nil { return mgclients.ClientsPage{}, err } - if err := svc.checkSuperAdmin(ctx, userID); err == nil { - pm.Role = mgclients.AllRole - pg, err := svc.clients.RetrieveAll(ctx, pm) - if err != nil { - return mgclients.ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) - } - return pg, err + if err := svc.checkSuperAdmin(ctx, userID); err != nil { + return mgclients.ClientsPage{}, err } - pg, err := svc.clients.SearchClients(ctx, pm) + pm.Role = mgclients.AllRole + pg, err := svc.clients.RetrieveAll(ctx, pm) if err != nil { return mgclients.ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) } - for i, c := range pg.Clients { - pg.Clients[i] = mgclients.Client{ID: c.ID, Name: c.Name} + return pg, err +} + +func (svc service) SearchUsers(ctx context.Context, token string, pm mgclients.Page) (mgclients.ClientsPage, error) { + _, err := svc.Identify(ctx, token) + if err != nil { + return mgclients.ClientsPage{}, err + } + + page := mgclients.Page{ + Offset: pm.Offset, + Limit: pm.Limit, + Name: pm.Name, + Id: pm.Id, + Role: mgclients.UserRole, + } + + cp, err := svc.clients.SearchClients(ctx, page) + if err != nil { + return mgclients.ClientsPage{}, errors.Wrap(svcerr.ErrViewEntity, err) } - return pg, nil + return cp, nil } func (svc service) UpdateClient(ctx context.Context, token string, cli mgclients.Client) (mgclients.Client, error) { diff --git a/users/service_test.go b/users/service_test.go index 7244e5b532..ae9c6febe2 100644 --- a/users/service_test.go +++ b/users/service_test.go @@ -534,7 +534,7 @@ func TestListClients(t *testing.T) { authorizeResponse: &magistrala.AuthorizeRes{Authorized: false}, token: validToken, authorizeErr: svcerr.ErrAuthorization, - err: nil, + err: svcerr.ErrAuthorization, }, { desc: "list clients as admin with failed to retrieve clients", @@ -557,29 +557,7 @@ func TestListClients(t *testing.T) { authorizeResponse: &magistrala.AuthorizeRes{Authorized: false}, token: validToken, superAdminErr: svcerr.ErrAuthorization, - err: nil, - }, - { - desc: "list clients as normal user successfully", - page: mgclients.Page{ - Total: 1, - }, - identifyResponse: &magistrala.IdentityRes{UserId: client.ID}, - authorizeResponse: &magistrala.AuthorizeRes{Authorized: false}, - retrieveAllResponse: mgclients.ClientsPage{ - Page: mgclients.Page{ - Total: 1, - }, - Clients: []mgclients.Client{basicClient}, - }, - response: mgclients.ClientsPage{ - Page: mgclients.Page{ - Total: 1, - }, - Clients: []mgclients.Client{basicClient}, - }, - token: validToken, - err: nil, + err: svcerr.ErrAuthorization, }, { desc: "list clients as normal user with failed to retrieve clients", @@ -591,7 +569,7 @@ func TestListClients(t *testing.T) { retrieveAllResponse: mgclients.ClientsPage{}, token: validToken, retrieveAllErr: repoerr.ErrNotFound, - err: svcerr.ErrViewEntity, + err: svcerr.ErrAuthorization, }, } @@ -600,7 +578,6 @@ func TestListClients(t *testing.T) { authCall1 := auth.On("Authorize", context.Background(), mock.Anything).Return(tc.authorizeResponse, tc.authorizeErr) repoCall := cRepo.On("CheckSuperAdmin", context.Background(), mock.Anything).Return(tc.superAdminErr) repoCall1 := cRepo.On("RetrieveAll", context.Background(), mock.Anything).Return(tc.retrieveAllResponse, tc.retrieveAllErr) - repoCall2 := cRepo.On("SearchClients", context.Background(), mock.Anything).Return(tc.response, tc.err) page, err := svc.ListClients(context.Background(), tc.token, tc.page) assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err)) assert.Equal(t, tc.response, page, fmt.Sprintf("%s: expected %v got %v\n", tc.desc, tc.response, page)) @@ -612,7 +589,88 @@ func TestListClients(t *testing.T) { authCall1.Unset() repoCall.Unset() repoCall1.Unset() - repoCall2.Unset() + } +} + +func TestSearchUsers(t *testing.T) { + svc, cRepo, auth, _ := newService(true) + cases := []struct { + desc string + token string + page mgclients.Page + identifyResp *magistrala.IdentityRes + authorizeResponse *magistrala.AuthorizeRes + response mgclients.ClientsPage + responseErr error + identifyErr error + authorizeErr error + checkSuperAdminErr error + err error + }{ + { + desc: "search clients with valid token", + token: validToken, + page: mgclients.Page{Offset: 0, Name: "clientname", Limit: 100}, + response: mgclients.ClientsPage{ + Page: mgclients.Page{Total: 1, Offset: 0, Limit: 100}, + Clients: []mgclients.Client{client}, + }, + identifyResp: &magistrala.IdentityRes{UserId: client.ID}, + authorizeResponse: &magistrala.AuthorizeRes{Authorized: true}, + }, + { + desc: "search clients with invalid token", + token: inValidToken, + page: mgclients.Page{Offset: 0, Name: "clientname", Limit: 100}, + response: mgclients.ClientsPage{}, + responseErr: svcerr.ErrAuthentication, + err: svcerr.ErrAuthentication, + }, + { + desc: "search clients with id", + token: validToken, + page: mgclients.Page{Offset: 0, Id: "d8dd12ef-aa2a-43fe-8ef2-2e4fe514360f", Limit: 100}, + response: mgclients.ClientsPage{ + Page: mgclients.Page{Total: 1, Offset: 0, Limit: 100}, + Clients: []mgclients.Client{client}, + }, + identifyResp: &magistrala.IdentityRes{UserId: client.ID}, + authorizeResponse: &magistrala.AuthorizeRes{Authorized: true}, + }, + { + desc: "search clients with random name", + token: validToken, + page: mgclients.Page{Offset: 0, Name: "randomname", Limit: 100}, + response: mgclients.ClientsPage{ + Page: mgclients.Page{Total: 0, Offset: 0, Limit: 100}, + Clients: []mgclients.Client{}, + }, + identifyResp: &magistrala.IdentityRes{UserId: client.ID}, + authorizeResponse: &magistrala.AuthorizeRes{Authorized: true}, + }, + { + desc: "search clients as a normal user", + token: validToken, + page: mgclients.Page{Offset: 0, Identity: "clientidentity", Limit: 100}, + response: mgclients.ClientsPage{}, + authorizeResponse: &magistrala.AuthorizeRes{Authorized: false}, + checkSuperAdminErr: svcerr.ErrAuthorization, + responseErr: nil, + }, + } + + for _, tc := range cases { + authCall := auth.On("Identify", context.Background(), &magistrala.IdentityReq{Token: tc.token}).Return(tc.identifyResp, tc.identifyErr) + authCall1 := auth.On("Authorize", context.Background(), mock.Anything).Return(tc.authorizeResponse, tc.authorizeErr) + repoCall := cRepo.On("CheckSuperAdmin", context.Background(), mock.Anything).Return(tc.checkSuperAdminErr) + repoCall1 := cRepo.On("SearchClients", context.Background(), mock.Anything).Return(tc.response, tc.responseErr) + page, err := svc.SearchUsers(context.Background(), tc.token, tc.page) + assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err)) + assert.Equal(t, tc.response, page, fmt.Sprintf("%s: expected %v got %v\n", tc.desc, tc.response, page)) + authCall.Unset() + authCall1.Unset() + repoCall.Unset() + repoCall1.Unset() } } diff --git a/users/tracing/tracing.go b/users/tracing/tracing.go index ff93c915b6..b69b32e822 100644 --- a/users/tracing/tracing.go +++ b/users/tracing/tracing.go @@ -71,6 +71,14 @@ func (tm *tracingMiddleware) ListClients(ctx context.Context, token string, pm m return tm.svc.ListClients(ctx, token, pm) } +// SearchUsers traces the "SearchUsers" operation of the wrapped clients.Service. +func (tm *tracingMiddleware) SearchUsers(ctx context.Context, token string, pm mgclients.Page) (mgclients.ClientsPage, error) { + ctx, span := tm.tracer.Start(ctx, "svc_search_clients", trace.WithAttributes(attribute.String("token", token))) + defer span.End() + + return tm.svc.SearchUsers(ctx, token, pm) +} + // UpdateClient traces the "UpdateClient" operation of the wrapped clients.Service. func (tm *tracingMiddleware) UpdateClient(ctx context.Context, token string, cli mgclients.Client) (mgclients.Client, error) { ctx, span := tm.tracer.Start(ctx, "svc_update_client_name_and_metadata", trace.WithAttributes(