satellite/console: add endpoint to request project limit increase

create endpoint to allow pro users to request project limit increase.

github issue: https://github.com/storj/storj/issues/6298

Change-Id: I96c3dff8bf0906904d199fc2c7ee738f3e6b04a3
This commit is contained in:
Cameron 2023-10-04 17:11:56 -04:00 committed by Storj Robot
parent 22ad017f12
commit e072b37a86
5 changed files with 125 additions and 2 deletions

View File

@ -366,7 +366,9 @@ func (service *Service) TrackRequestLimitIncrease(userID uuid.UUID, email string
props := segment.NewProperties()
props.Set("email", email)
props.Set("satellite", service.satelliteName)
props.Set("project", info.ProjectName)
if info.ProjectName != "" {
props.Set("project", info.ProjectName)
}
props.Set("type", info.LimitType)
props.Set("currentLimit", info.CurrentLimit)
props.Set("desiredLimit", info.DesiredLimit)

View File

@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
@ -1074,6 +1075,23 @@ func (a *Auth) SetUserSettings(w http.ResponseWriter, r *http.Request) {
}
}
// RequestLimitIncrease handles requesting increase for project limit.
func (a *Auth) RequestLimitIncrease(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
b, err := io.ReadAll(r.Body)
if err != nil {
a.serveJSONError(ctx, w, err)
}
err = a.service.RequestProjectLimitIncrease(ctx, string(b))
if err != nil {
a.serveJSONError(ctx, w, err)
}
}
// serveJSONError writes JSON error to response output stream.
func (a *Auth) serveJSONError(ctx context.Context, w http.ResponseWriter, err error) {
status := a.getStatusCode(err)
@ -1085,7 +1103,7 @@ func (a *Auth) getStatusCode(err error) int {
var maxBytesError *http.MaxBytesError
switch {
case console.ErrValidation.Has(err), console.ErrCaptcha.Has(err), console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err), console.ErrChangePassword.Has(err):
case console.ErrValidation.Has(err), console.ErrCaptcha.Has(err), console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err), console.ErrChangePassword.Has(err), console.ErrInvalidProjectLimit.Has(err):
return http.StatusBadRequest
case console.ErrUnauthorized.Has(err), console.ErrTokenExpiration.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err):
return http.StatusUnauthorized
@ -1093,6 +1111,8 @@ func (a *Auth) getStatusCode(err error) int {
return http.StatusConflict
case errors.Is(err, errNotImplemented):
return http.StatusNotImplemented
case console.ErrNotPaidTier.Has(err):
return http.StatusPaymentRequired
case errors.As(err, &maxBytesError):
return http.StatusRequestEntityTooLarge
default:

View File

@ -731,6 +731,68 @@ func TestRegistrationEmail(t *testing.T) {
})
}
func TestIncreaseLimit(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
proUser, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test Pro User",
Email: "testpro@mail.test",
}, 1)
require.NoError(t, err)
proUser.PaidTier = true
require.NoError(t, sat.DB.Console().Users().Update(ctx, proUser.ID, console.UpdateUserRequest{PaidTier: &proUser.PaidTier}))
freeUser, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test Free User",
Email: "testfree@mail.test",
}, 1)
require.NoError(t, err)
endpoint := "auth/limit-increase"
tests := []struct {
user *console.User
input string
expectedStatus int
}{
{ // Happy path
user: proUser, input: "10", expectedStatus: http.StatusOK,
},
{ // non-integer input
user: proUser, input: "1000 projects please", expectedStatus: http.StatusBadRequest,
},
{ // other non-integer input
user: proUser, input: "7.5", expectedStatus: http.StatusBadRequest,
},
{ // another non-integer input
user: proUser, input: "7.0", expectedStatus: http.StatusBadRequest,
},
{ // requested limit zero
user: proUser, input: "0", expectedStatus: http.StatusBadRequest,
},
{ // requested limit negative
user: proUser, input: "-1", expectedStatus: http.StatusBadRequest,
},
{ // requested limit not greater than current limit
user: proUser, input: "1", expectedStatus: http.StatusBadRequest,
},
{ // free tier user
user: freeUser, input: "10", expectedStatus: http.StatusPaymentRequired,
},
}
for _, tt := range tests {
_, status, err := doRequestWithAuth(ctx, t, sat, tt.user, http.MethodPatch, endpoint, bytes.NewBuffer([]byte(tt.input)))
require.NoError(t, err)
require.Equal(t, tt.expectedStatus, status)
}
})
}
func TestResendActivationEmail(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,

View File

@ -322,6 +322,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
authRouter.Handle("/resend-email/{email}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResendEmail))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/refresh-session", server.withAuth(http.HandlerFunc(authController.RefreshSession))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/limit-increase", server.withAuth(http.HandlerFunc(authController.RequestLimitIncrease))).Methods(http.MethodPatch, http.MethodOptions)
if config.ABTesting.Enabled {
abController := consoleapi.NewABTesting(logger, abTesting)

View File

@ -13,6 +13,7 @@ import (
"net/http"
"net/mail"
"sort"
"strconv"
"strings"
"time"
@ -146,6 +147,12 @@ var (
// ErrAlreadyInvited occurs when trying to invite a user who already has an unexpired invitation.
ErrAlreadyInvited = errs.Class("user is already invited")
// ErrInvalidProjectLimit occurs when the requested project limit is not a non-negative integer and/or greater than the current project limit.
ErrInvalidProjectLimit = errs.Class("requested project limit is invalid")
// ErrNotPaidTier occurs when a user must be paid tier in order to complete an operation.
ErrNotPaidTier = errs.Class("user is not paid tier")
)
// Service is handling accounts related logic.
@ -1928,6 +1935,37 @@ func (s *Service) RequestLimitIncrease(ctx context.Context, projectID uuid.UUID,
return nil
}
// RequestProjectLimitIncrease is a method for requesting to increase max number of projects for a user.
func (s *Service) RequestProjectLimitIncrease(ctx context.Context, limit string) (err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "request project limit increase")
if err != nil {
return Error.Wrap(err)
}
if !user.PaidTier {
return ErrNotPaidTier.New("Only Pro users may request project limit increases")
}
limitInt, err := strconv.Atoi(limit)
if err != nil {
return ErrInvalidProjectLimit.New("Requested project limit must be an integer")
}
if limitInt <= user.ProjectLimit {
return ErrInvalidProjectLimit.New("Requested project limit (%d) must be greater than current limit (%d)", limitInt, user.ProjectLimit)
}
s.analytics.TrackRequestLimitIncrease(user.ID, user.Email, analytics.LimitRequestInfo{
LimitType: "projects",
CurrentLimit: fmt.Sprint(user.ProjectLimit),
DesiredLimit: limit,
})
return nil
}
// GenUpdateProject is a method for updating project name and description by id for generated api.
func (s *Service) GenUpdateProject(ctx context.Context, projectID uuid.UUID, projectInfo UpsertProjectInfo) (p *Project, httpError api.HTTPError) {
var err error