From 683119b83508581f33aef3a620e3cf659be20ab3 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 2 Aug 2023 11:32:53 -0400 Subject: [PATCH] satellite/console: get project members endpoint This commit adds a new endpoint on the satellite console api to get project members and invitations. issue: https://github.com/storj/storj/issues/6137 Change-Id: I66cb064eeaffb1c34878462b3e6b3be8f3629f4e --- .../console/consoleweb/consoleapi/projects.go | 145 +++++++ .../consoleweb/consoleapi/projects_test.go | 384 ++++++++++++++++++ satellite/console/consoleweb/server.go | 1 + satellite/console/projectmembers.go | 7 +- 4 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 satellite/console/consoleweb/consoleapi/projects_test.go diff --git a/satellite/console/consoleweb/consoleapi/projects.go b/satellite/console/consoleweb/consoleapi/projects.go index 9fa7af29a..08aa6678e 100644 --- a/satellite/console/consoleweb/consoleapi/projects.go +++ b/satellite/console/consoleweb/consoleapi/projects.go @@ -27,6 +27,33 @@ type Projects struct { service *console.Service } +// ProjectMembersPage contains information about a page of project members and invitations. +type ProjectMembersPage struct { + Members []Member `json:"projectMembers"` + Invitations []Invitation `json:"projectInvitations"` + TotalCount int `json:"totalCount"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Order int `json:"order"` + OrderDirection int `json:"orderDirection"` + Search string `json:"search"` + CurrentPage int `json:"currentPage"` + PageCount int `json:"pageCount"` +} + +// Member is a project member in a ProjectMembersPage. +type Member struct { + User *console.User `json:"user"` + JoinedAt time.Time `json:"joinedAt"` +} + +// Invitation is a project invitation in a ProjectMembersPage. +type Invitation struct { + Email string `json:"email"` + CreatedAt time.Time `json:"createdAt"` + Expired bool `json:"expired"` +} + // NewProjects is a constructor for api analytics controller. func NewProjects(log *zap.Logger, service *console.Service) *Projects { return &Projects{ @@ -138,6 +165,124 @@ func (p *Projects) GetPagedProjects(w http.ResponseWriter, r *http.Request) { } } +// GetMembersAndInvitations returns the project's members and invitees. +func (p *Projects) GetMembersAndInvitations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + idParam, ok := mux.Vars(r)["id"] + if !ok { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing id route param")) + return + } + + publicID, err := uuid.FromString(idParam) + if err != nil { + p.serveJSONError(ctx, w, http.StatusBadRequest, err) + } + + project, err := p.service.GetProject(ctx, publicID) + if err != nil { + p.serveJSONError(ctx, w, http.StatusInternalServerError, err) + return + } + + limitStr := r.URL.Query().Get("limit") + if limitStr == "" { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing limit query param")) + return + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid limit parameter: %s", limitStr)) + return + } + + search := r.URL.Query().Get("search") + + pageStr := r.URL.Query().Get("page") + if pageStr == "" { + pageStr = "1" + } + page, err := strconv.Atoi(pageStr) + if err != nil { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid page parameter: %s", pageStr)) + return + } + + orderStr := r.URL.Query().Get("order") + if orderStr == "" { + orderStr = "1" + } + order, err := strconv.Atoi(orderStr) + if err != nil { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid order parameter: %s", orderStr)) + return + } + + orderDirStr := r.URL.Query().Get("order-direction") + if orderDirStr == "" { + orderDirStr = "1" + } + orderDir, err := strconv.Atoi(orderDirStr) + if err != nil { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid order-direction parameter: %s", orderDirStr)) + return + } + + var memberPage ProjectMembersPage + membersAndInvitations, err := p.service.GetProjectMembersAndInvitations(ctx, project.ID, console.ProjectMembersCursor{ + Search: search, + Limit: uint(limit), + Page: uint(page), + Order: console.ProjectMemberOrder(order), + OrderDirection: console.OrderDirection(orderDir), + }) + if err != nil { + p.serveJSONError(ctx, w, http.StatusUnauthorized, err) + return + } + memberPage.Search = membersAndInvitations.Search + memberPage.Limit = int(membersAndInvitations.Limit) + memberPage.Order = int(membersAndInvitations.Order) + memberPage.OrderDirection = int(membersAndInvitations.OrderDirection) + memberPage.Offset = int(membersAndInvitations.Offset) + memberPage.PageCount = int(membersAndInvitations.PageCount) + memberPage.CurrentPage = int(membersAndInvitations.CurrentPage) + memberPage.TotalCount = int(membersAndInvitations.TotalCount) + memberPage.Members = []Member{} + memberPage.Invitations = []Invitation{} + + for _, m := range membersAndInvitations.ProjectMembers { + user, err := p.service.GetUser(ctx, m.MemberID) + if err != nil { + p.serveJSONError(ctx, w, http.StatusInternalServerError, err) + return + } + member := Member{ + User: user, + JoinedAt: m.CreatedAt, + } + memberPage.Members = append(memberPage.Members, member) + } + for _, inv := range membersAndInvitations.ProjectInvitations { + invitee := Invitation{ + Email: inv.Email, + CreatedAt: inv.CreatedAt, + Expired: p.service.IsProjectInvitationExpired(&inv), + } + memberPage.Invitations = append(memberPage.Invitations, invitee) + } + err = json.NewEncoder(w).Encode(memberPage) + if err != nil { + p.serveJSONError(ctx, w, http.StatusInternalServerError, err) + } +} + // GetSalt returns the project's salt. func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/satellite/console/consoleweb/consoleapi/projects_test.go b/satellite/console/consoleweb/consoleapi/projects_test.go new file mode 100644 index 000000000..63addbca1 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/projects_test.go @@ -0,0 +1,384 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleapi_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "storj.io/common/memory" + "storj.io/common/testcontext" + "storj.io/common/testrand" + "storj.io/common/uuid" + "storj.io/storj/private/testplanet" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/console/consoleweb/consoleapi" +) + +func createTestMembers(ctx context.Context, t *testing.T, db console.DB, p uuid.UUID, owner *uuid.UUID) (_ map[uuid.UUID]console.User, _ map[string]console.User) { + members := make(map[uuid.UUID]console.User) + invitees := make(map[string]console.User) + for i := 0; i < 3; i++ { + memberID := testrand.UUID() + member, err := db.Users().Insert(ctx, &console.User{ + ID: memberID, + FullName: fmt.Sprintf("Member FullName%c", rune('A'+i)), + ShortName: fmt.Sprintf("Member ShortName%c", rune('A'+i)), + Email: fmt.Sprintf("member%d@storj.test", i), + ProjectLimit: 1, + ProjectStorageLimit: (memory.GB * 150).Int64(), + ProjectBandwidthLimit: (memory.GB * 150).Int64(), + PasswordHash: []byte("test"), + }) + require.NoError(t, err) + + members[memberID] = *member + + _, err = db.ProjectMembers().Insert(ctx, member.ID, p) + require.NoError(t, err) + + inviteeID := testrand.UUID() + inviteeEmail := fmt.Sprintf("invitee%d@storj.test", i) + invitee, err := db.Users().Insert(ctx, &console.User{ + ID: inviteeID, + FullName: fmt.Sprintf("Invitee FullName%c", rune('A'+i)), + ShortName: fmt.Sprintf("Invitee ShortName%c", rune('A'+i)), + Email: inviteeEmail, + ProjectLimit: 1, + ProjectStorageLimit: (memory.GB * 150).Int64(), + ProjectBandwidthLimit: (memory.GB * 150).Int64(), + PasswordHash: []byte("test"), + }) + require.NoError(t, err) + + invitees[inviteeEmail] = *invitee + + _, err = db.ProjectInvitations().Upsert(ctx, &console.ProjectInvitation{ + ProjectID: p, + Email: inviteeEmail, + InviterID: owner, + CreatedAt: time.Now(), + }) + require.NoError(t, err) + } + return members, invitees +} + +func TestGetProjectMembersAndInvitationsOrdering(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + p := planet.Uplinks[0].Projects[0].ID + + user, err := sat.DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].User[sat.ID()].Email) + require.NoError(t, err) + + members, invitees := createTestMembers(ctx, t, sat.DB.Console(), p, &user.ID) + members[user.ID] = *user + + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + require.NoError(t, err) + + expire := time.Now().AddDate(0, 0, 1) + cookie := http.Cookie{ + Name: "_tokenKey", + Path: "/", + Value: tokenInfo.Token.String(), + Expires: expire, + } + + tests := []struct { + order, orderDir int + }{ + { // ascending by name + order: 1, + orderDir: 1, + }, + { // descending by name + order: 1, + orderDir: 2, + }, + { // ascending by email + order: 2, + orderDir: 1, + }, + { // descending by email + order: 2, + orderDir: 2, + }, + { // ascending by created at + order: 3, + orderDir: 1, + }, + { // descending by created at + order: 3, + orderDir: 2, + }, + } + + for _, tt := range tests { + addr := planet.Satellites[0].API.Console.Listener.Addr().String() + + url := fmt.Sprintf("http://%s/api/v0/projects/%s/members", addr, p.String()) + url += fmt.Sprintf("?limit=100&page=1&order=%d&order-direction=%d", tt.order, tt.orderDir) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + require.NoError(t, err) + + req.AddCookie(&cookie) + + client := http.Client{} + res, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, res) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + + var membersAndInvitations consoleapi.ProjectMembersPage + require.NoError(t, json.Unmarshal(body, &membersAndInvitations)) + + respMembers := membersAndInvitations.Members + respInvitees := membersAndInvitations.Invitations + for i := 1; i < len(respMembers); i++ { + switch tt.orderDir { + case int(console.Ascending): + switch tt.order { + case int(console.Name): + require.Less(t, members[respMembers[i-1].User.ID].FullName, members[respMembers[i].User.ID].FullName) + case int(console.Email): + require.Less(t, members[respMembers[i-1].User.ID].Email, members[respMembers[i].User.ID].Email) + case int(console.Created): + require.Less(t, members[respMembers[i-1].User.ID].CreatedAt, members[respMembers[i].User.ID].CreatedAt) + default: + t.Error("invalid order", tt.order) + } + case int(console.Descending): + switch tt.order { + case int(console.Name): + require.Greater(t, members[respMembers[i-1].User.ID].FullName, members[respMembers[i].User.ID].FullName) + case int(console.Email): + require.Greater(t, members[respMembers[i-1].User.ID].Email, members[respMembers[i].User.ID].Email) + case int(console.Created): + require.Greater(t, members[respMembers[i-1].User.ID].CreatedAt, members[respMembers[i].User.ID].CreatedAt) + default: + t.Error("invalid order", tt.order) + } + default: + t.Error("invalid order direction", tt.orderDir) + } + } + for i := 1; i < len(respInvitees); i++ { + switch tt.orderDir { + case int(console.Ascending): + switch tt.order { + case int(console.Name): + require.Less(t, invitees[respInvitees[i-1].Email].FullName, invitees[respInvitees[i].Email].FullName) + case int(console.Email): + require.Less(t, invitees[respInvitees[i-1].Email].Email, invitees[respInvitees[i].Email].Email) + case int(console.Created): + require.Less(t, invitees[respInvitees[i-1].Email].CreatedAt, invitees[respInvitees[i].Email].CreatedAt) + default: + t.Error("invalid order", tt.order) + } + case int(console.Descending): + switch tt.order { + case int(console.Name): + require.Greater(t, invitees[respInvitees[i-1].Email].FullName, invitees[respInvitees[i].Email].FullName) + case int(console.Email): + require.Greater(t, invitees[respInvitees[i-1].Email].Email, invitees[respInvitees[i].Email].Email) + case int(console.Created): + require.Greater(t, invitees[respInvitees[i-1].Email].CreatedAt, invitees[respInvitees[i].Email].CreatedAt) + default: + t.Error("invalid order", tt.order) + } + default: + t.Error("invalid order direction", tt.orderDir) + } + } + } + }) +} + +func TestGetProjectMembersAndInvitationsSearch(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + p := planet.Uplinks[0].Projects[0].ID + + user, err := sat.DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].User[sat.ID()].Email) + require.NoError(t, err) + + members, invitees := createTestMembers(ctx, t, sat.DB.Console(), p, &user.ID) + members[user.ID] = *user + + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + require.NoError(t, err) + + expire := time.Now().AddDate(0, 0, 1) + cookie := http.Cookie{ + Name: "_tokenKey", + Path: "/", + Value: tokenInfo.Token.String(), + Expires: expire, + } + + tests := []struct { + search string + expectedMembers, expectedInvitees int + }{ + { // all members and invitees + "", + 4, + 3, + }, + { // zero members zero invitees + "asdf", + 0, + 0, + }, + { // one member one invitee by email + "1", + 1, + 1, + }, + { // three members by full name + "Member FullName", + 3, + 0, + }, + { // three invitees by email + "invitee", + 0, + 3, + }, + { // one member by short name + "Member ShortNameA", + 1, + 0, + }, + } + + for _, tt := range tests { + addr := planet.Satellites[0].API.Console.Listener.Addr().String() + + endpoint := fmt.Sprintf("http://%s/api/v0/projects/%s/members?limit=100&page=1&order=1&order-direction=1&", addr, p.String()) + params := url.Values{} + params.Add("search", tt.search) + endpoint += params.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + require.NoError(t, err) + + req.AddCookie(&cookie) + + client := http.Client{} + res, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, res) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + + var membersAndInvitations consoleapi.ProjectMembersPage + require.NoError(t, json.Unmarshal(body, &membersAndInvitations)) + + respMembers := membersAndInvitations.Members + respInvitees := membersAndInvitations.Invitations + require.Equal(t, tt.expectedMembers, len(respMembers)) + require.Equal(t, tt.expectedInvitees, len(respInvitees)) + if tt.search != "" { + for _, m := range respMembers { + containsSearch := strings.Contains(members[m.User.ID].Email, tt.search) || strings.Contains(members[m.User.ID].FullName, tt.search) || strings.Contains(members[m.User.ID].ShortName, tt.search) + require.True(t, containsSearch) + } + for _, inv := range respInvitees { + containsSearch := strings.Contains(inv.Email, tt.search) || strings.Contains(invitees[inv.Email].FullName, tt.search) || strings.Contains(invitees[inv.Email].ShortName, tt.search) + require.True(t, containsSearch) + } + } + } + }) +} + +func TestGetProjectMembersAndInvitationsLimitAndPage(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + p := planet.Uplinks[0].Projects[0].ID + + user, err := sat.DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].User[sat.ID()].Email) + require.NoError(t, err) + + members, _ := createTestMembers(ctx, t, sat.DB.Console(), p, &user.ID) + members[user.ID] = *user + + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + require.NoError(t, err) + + expire := time.Now().AddDate(0, 0, 1) + cookie := http.Cookie{ + Name: "_tokenKey", + Path: "/", + Value: tokenInfo.Token.String(), + Expires: expire, + } + + addr := planet.Satellites[0].API.Console.Listener.Addr().String() + + limit := 1 + page := 1 + var previousResult console.ProjectMembersPage + for i := 0; i < 2; i++ { + endpoint := fmt.Sprintf("http://%s/api/v0/projects/%s/members?order=1&order-direction=1&", addr, p.String()) + params := url.Values{} + params.Add("limit", fmt.Sprint(limit)) + params.Add("page", fmt.Sprint(page)) + endpoint += params.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + require.NoError(t, err) + + req.AddCookie(&cookie) + + client := http.Client{} + res, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, res) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + + var membersAndInvitations console.ProjectMembersPage + require.NoError(t, json.Unmarshal(body, &membersAndInvitations)) + + respMembers := membersAndInvitations.ProjectMembers + respInvitees := membersAndInvitations.ProjectInvitations + length := len(respMembers) + len(respInvitees) + require.Equal(t, limit, length) + require.Equal(t, page, int(membersAndInvitations.CurrentPage)) + if i != 0 { + require.NotEqual(t, previousResult, membersAndInvitations) + } + previousResult = membersAndInvitations + limit++ + page++ + } + }) +} diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 5e4b75e83..bd1057629 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -278,6 +278,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc projectsRouter.Handle("", http.HandlerFunc(projectsController.GetUserProjects)).Methods(http.MethodGet, http.MethodOptions) projectsRouter.Handle("/paged", http.HandlerFunc(projectsController.GetPagedProjects)).Methods(http.MethodGet, http.MethodOptions) projectsRouter.Handle("/{id}/salt", http.HandlerFunc(projectsController.GetSalt)).Methods(http.MethodGet, http.MethodOptions) + projectsRouter.Handle("/{id}/members", http.HandlerFunc(projectsController.GetMembersAndInvitations)).Methods(http.MethodGet, http.MethodOptions) projectsRouter.Handle("/{id}/invite", http.HandlerFunc(projectsController.InviteUsers)).Methods(http.MethodPost, http.MethodOptions) projectsRouter.Handle("/{id}/invite-link", http.HandlerFunc(projectsController.GetInviteLink)).Methods(http.MethodGet, http.MethodOptions) projectsRouter.Handle("/invitations", http.HandlerFunc(projectsController.GetUserInvitations)).Methods(http.MethodGet, http.MethodOptions) diff --git a/satellite/console/projectmembers.go b/satellite/console/projectmembers.go index 97c1bcc27..4fd3c6d9b 100644 --- a/satellite/console/projectmembers.go +++ b/satellite/console/projectmembers.go @@ -53,10 +53,9 @@ type ProjectMembersPage struct { Order ProjectMemberOrder OrderDirection OrderDirection Offset uint64 - - PageCount uint - CurrentPage uint - TotalCount uint64 + PageCount uint + CurrentPage uint + TotalCount uint64 } // ProjectMemberOrder is used for querying project members in specified order.