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
This commit is contained in:
Cameron 2023-08-02 11:32:53 -04:00
parent 887209bc24
commit 683119b835
4 changed files with 533 additions and 4 deletions

View File

@ -27,6 +27,33 @@ type Projects struct {
service *console.Service 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. // NewProjects is a constructor for api analytics controller.
func NewProjects(log *zap.Logger, service *console.Service) *Projects { func NewProjects(log *zap.Logger, service *console.Service) *Projects {
return &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. // GetSalt returns the project's salt.
func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) { func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()

View File

@ -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++
}
})
}

View File

@ -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("", http.HandlerFunc(projectsController.GetUserProjects)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/paged", http.HandlerFunc(projectsController.GetPagedProjects)).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}/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", 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("/{id}/invite-link", http.HandlerFunc(projectsController.GetInviteLink)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/invitations", http.HandlerFunc(projectsController.GetUserInvitations)).Methods(http.MethodGet, http.MethodOptions) projectsRouter.Handle("/invitations", http.HandlerFunc(projectsController.GetUserInvitations)).Methods(http.MethodGet, http.MethodOptions)

View File

@ -53,10 +53,9 @@ type ProjectMembersPage struct {
Order ProjectMemberOrder Order ProjectMemberOrder
OrderDirection OrderDirection OrderDirection OrderDirection
Offset uint64 Offset uint64
PageCount uint
PageCount uint CurrentPage uint
CurrentPage uint TotalCount uint64
TotalCount uint64
} }
// ProjectMemberOrder is used for querying project members in specified order. // ProjectMemberOrder is used for querying project members in specified order.