satellite/console: add API methods for responding to project invites
API endpoints and associated methods have been implemented to allow users to accept or decline their pending project member invitations through the satellite frontend. References #5855 Change-Id: Ic23721c64a65e741dc1015838e617fd1af5c8ca4
This commit is contained in:
parent
b64be2338d
commit
6359c537c0
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/zeebo/errs"
|
"github.com/zeebo/errs"
|
||||||
@ -64,6 +65,106 @@ func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserInvitations returns the user's pending project member invitations.
|
||||||
|
func (p *Projects) GetUserInvitations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
var err error
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
invites, err := p.service.GetUserProjectInvitations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
p.serveJSONError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonInvite struct {
|
||||||
|
ProjectID uuid.UUID `json:"projectID"`
|
||||||
|
ProjectName string `json:"projectName"`
|
||||||
|
ProjectDescription string `json:"projectDescription"`
|
||||||
|
InviterEmail string `json:"inviterEmail"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]jsonInvite, 0)
|
||||||
|
|
||||||
|
for _, invite := range invites {
|
||||||
|
proj, err := p.service.GetProjectNoAuth(ctx, invite.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
p.serveJSONError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respInvite := jsonInvite{
|
||||||
|
ProjectID: proj.PublicID,
|
||||||
|
ProjectName: proj.Name,
|
||||||
|
ProjectDescription: proj.Description,
|
||||||
|
CreatedAt: invite.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if invite.InviterID != nil {
|
||||||
|
inviter, err := p.service.GetUser(ctx, *invite.InviterID)
|
||||||
|
if err != nil {
|
||||||
|
p.serveJSONError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respInvite.InviterEmail = inviter.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
response = append(response, respInvite)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).Encode(response)
|
||||||
|
if err != nil {
|
||||||
|
p.serveJSONError(w, http.StatusInternalServerError, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespondToInvitation handles accepting or declining a user's project member invitation.
|
||||||
|
func (p *Projects) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
var err error
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
var idParam string
|
||||||
|
|
||||||
|
if idParam, ok = mux.Vars(r)["id"]; !ok {
|
||||||
|
p.serveJSONError(w, http.StatusBadRequest, errs.New("missing project id route param"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.FromString(idParam)
|
||||||
|
if err != nil {
|
||||||
|
p.serveJSONError(w, http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Response console.ProjectInvitationResponse `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if err != nil {
|
||||||
|
p.serveJSONError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.service.RespondToProjectInvitation(ctx, id, payload.Response)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case console.ErrAlreadyMember.Has(err):
|
||||||
|
status = http.StatusConflict
|
||||||
|
case console.ErrProjectInviteInvalid.Has(err):
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case console.ErrValidation.Has(err):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
p.serveJSONError(w, status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// serveJSONError writes JSON error to response output stream.
|
// serveJSONError writes JSON error to response output stream.
|
||||||
func (p *Projects) serveJSONError(w http.ResponseWriter, status int, err error) {
|
func (p *Projects) serveJSONError(w http.ResponseWriter, status int, err error) {
|
||||||
web.ServeJSONError(p.log, w, status, err)
|
web.ServeJSONError(p.log, w, status, err)
|
||||||
|
@ -251,32 +251,23 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
|
|||||||
consoleapi.NewUserManagement(logger, mon, server.service, router, &apiAuth{&server})
|
consoleapi.NewUserManagement(logger, mon, server.service, router, &apiAuth{&server})
|
||||||
}
|
}
|
||||||
|
|
||||||
projectsController := consoleapi.NewProjects(logger, service)
|
|
||||||
router.Handle(
|
|
||||||
"/api/v0/projects/{id}/salt",
|
|
||||||
server.withAuth(http.HandlerFunc(projectsController.GetSalt)),
|
|
||||||
).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
router.HandleFunc("/api/v0/config", server.frontendConfigHandler)
|
router.HandleFunc("/api/v0/config", server.frontendConfigHandler)
|
||||||
|
|
||||||
|
router.Handle("/api/v0/graphql", server.withAuth(http.HandlerFunc(server.graphqlHandler)))
|
||||||
|
|
||||||
router.HandleFunc("/registrationToken/", server.createRegistrationTokenHandler)
|
router.HandleFunc("/registrationToken/", server.createRegistrationTokenHandler)
|
||||||
router.HandleFunc("/robots.txt", server.seoHandler)
|
router.HandleFunc("/robots.txt", server.seoHandler)
|
||||||
|
|
||||||
router.Handle("/api/v0/graphql", server.withAuth(http.HandlerFunc(server.graphqlHandler)))
|
projectsController := consoleapi.NewProjects(logger, service)
|
||||||
|
projectsRouter := router.PathPrefix("/api/v0/projects").Subrouter()
|
||||||
|
projectsRouter.Handle("/{id}/salt", server.withAuth(http.HandlerFunc(projectsController.GetSalt))).Methods(http.MethodGet)
|
||||||
|
projectsRouter.Handle("/invitations", server.withAuth(http.HandlerFunc(projectsController.GetUserInvitations))).Methods(http.MethodGet)
|
||||||
|
projectsRouter.Handle("/invitations/{id}/respond", server.withAuth(http.HandlerFunc(projectsController.RespondToInvitation))).Methods(http.MethodPost)
|
||||||
|
|
||||||
usageLimitsController := consoleapi.NewUsageLimits(logger, service)
|
usageLimitsController := consoleapi.NewUsageLimits(logger, service)
|
||||||
router.Handle(
|
projectsRouter.Handle("/{id}/usage-limits", server.withAuth(http.HandlerFunc(usageLimitsController.ProjectUsageLimits))).Methods(http.MethodGet)
|
||||||
"/api/v0/projects/{id}/usage-limits",
|
projectsRouter.Handle("/usage-limits", server.withAuth(http.HandlerFunc(usageLimitsController.TotalUsageLimits))).Methods(http.MethodGet)
|
||||||
server.withAuth(http.HandlerFunc(usageLimitsController.ProjectUsageLimits)),
|
projectsRouter.Handle("/{id}/daily-usage", server.withAuth(http.HandlerFunc(usageLimitsController.DailyUsage))).Methods(http.MethodGet)
|
||||||
).Methods(http.MethodGet)
|
|
||||||
router.Handle(
|
|
||||||
"/api/v0/projects/usage-limits",
|
|
||||||
server.withAuth(http.HandlerFunc(usageLimitsController.TotalUsageLimits)),
|
|
||||||
).Methods(http.MethodGet)
|
|
||||||
router.Handle(
|
|
||||||
"/api/v0/projects/{id}/daily-usage",
|
|
||||||
server.withAuth(http.HandlerFunc(usageLimitsController.DailyUsage)),
|
|
||||||
).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
authController := consoleapi.NewAuth(logger, service, accountFreezeService, mailService, server.cookieAuth, server.analytics, config.SatelliteName, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL, config.GeneralRequestURL)
|
authController := consoleapi.NewAuth(logger, service, accountFreezeService, mailService, server.cookieAuth, server.analytics, config.SatelliteName, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL, config.GeneralRequestURL)
|
||||||
authRouter := router.PathPrefix("/api/v0/auth").Subrouter()
|
authRouter := router.PathPrefix("/api/v0/auth").Subrouter()
|
||||||
|
@ -8,7 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -70,10 +69,13 @@ const (
|
|||||||
apiKeyWithNameDoesntExistErrMsg = "An API Key with this name doesn't exist in this project."
|
apiKeyWithNameDoesntExistErrMsg = "An API Key with this name doesn't exist in this project."
|
||||||
teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered.
|
teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered.
|
||||||
Please add team members with active accounts`
|
Please add team members with active accounts`
|
||||||
activationTokenExpiredErrMsg = "This activation token has expired, please request another one"
|
activationTokenExpiredErrMsg = "This activation token has expired, please request another one"
|
||||||
usedRegTokenErrMsg = "This registration token has already been used"
|
usedRegTokenErrMsg = "This registration token has already been used"
|
||||||
projLimitErrMsg = "Sorry, project creation is limited for your account. Please contact support!"
|
projLimitErrMsg = "Sorry, project creation is limited for your account. Please contact support!"
|
||||||
projNameErrMsg = "The new project must have a name you haven't used before!"
|
projNameErrMsg = "The new project must have a name you haven't used before!"
|
||||||
|
projInviteInvalidErrMsg = "The invitation has expired or is invalid"
|
||||||
|
projInviteAlreadyMemberErrMsg = "You are already a member of the project"
|
||||||
|
projInviteResponseInvalidErrMsg = "Invalid project member invitation response"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -133,6 +135,13 @@ var (
|
|||||||
|
|
||||||
// ErrAlreadyHasPackage is error that occurs when a user tries to update package, but already has one.
|
// ErrAlreadyHasPackage is error that occurs when a user tries to update package, but already has one.
|
||||||
ErrAlreadyHasPackage = errs.Class("user already has package")
|
ErrAlreadyHasPackage = errs.Class("user already has package")
|
||||||
|
|
||||||
|
// ErrAlreadyMember occurs when a user tries to reject an invitation to a project they're already a member of.
|
||||||
|
ErrAlreadyMember = errs.Class("already a member")
|
||||||
|
|
||||||
|
// ErrProjectInviteInvalid occurs when a user tries to respond to an invitation that doesn't exist
|
||||||
|
// or has expired.
|
||||||
|
ErrProjectInviteInvalid = errs.Class("invalid project invitation")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service is handling accounts related logic.
|
// Service is handling accounts related logic.
|
||||||
@ -179,6 +188,7 @@ type Config struct {
|
|||||||
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
|
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
|
||||||
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
|
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
|
||||||
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
|
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
|
||||||
|
ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"`
|
||||||
UsageLimits UsageLimitsConfig
|
UsageLimits UsageLimitsConfig
|
||||||
Captcha CaptchaConfig
|
Captcha CaptchaConfig
|
||||||
Session SessionConfig
|
Session SessionConfig
|
||||||
@ -1464,8 +1474,7 @@ func (s *Service) DeleteAccount(ctx context.Context, password string) (err error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProject is a method for querying project by id.
|
// GetProject is a method for querying project by internal or public ID.
|
||||||
// projectID here may be project.PublicID or project.ID.
|
|
||||||
func (s *Service) GetProject(ctx context.Context, projectID uuid.UUID) (p *Project, err error) {
|
func (s *Service) GetProject(ctx context.Context, projectID uuid.UUID) (p *Project, err error) {
|
||||||
defer mon.Task()(&ctx)(&err)
|
defer mon.Task()(&ctx)(&err)
|
||||||
user, err := s.getUserAndAuditLog(ctx, "get project", zap.String("projectID", projectID.String()))
|
user, err := s.getUserAndAuditLog(ctx, "get project", zap.String("projectID", projectID.String()))
|
||||||
@ -1483,6 +1492,27 @@ func (s *Service) GetProject(ctx context.Context, projectID uuid.UUID) (p *Proje
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProjectNoAuth is a method for querying project by ID or public ID.
|
||||||
|
// This is for internal use only as it ignores whether a user is authorized to perform this action.
|
||||||
|
// If authorization checking is required, use GetProject.
|
||||||
|
func (s *Service) GetProjectNoAuth(ctx context.Context, projectID uuid.UUID) (p *Project, err error) {
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
p, err = s.store.Projects().GetByPublicID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
if errs.Is(err, sql.ErrNoRows) {
|
||||||
|
p, err = s.store.Projects().Get(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Error.Wrap(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, Error.Wrap(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSalt is a method for querying project salt by id.
|
// GetSalt is a method for querying project salt by id.
|
||||||
// id may be project.ID or project.PublicID.
|
// id may be project.ID or project.PublicID.
|
||||||
func (s *Service) GetSalt(ctx context.Context, projectID uuid.UUID) (salt []byte, err error) {
|
func (s *Service) GetSalt(ctx context.Context, projectID uuid.UUID) (salt []byte, err error) {
|
||||||
@ -2938,16 +2968,9 @@ type isProjectMember struct {
|
|||||||
func (s *Service) isProjectOwner(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (isOwner bool, project *Project, err error) {
|
func (s *Service) isProjectOwner(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (isOwner bool, project *Project, err error) {
|
||||||
defer mon.Task()(&ctx)(&err)
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
project, err = s.store.Projects().GetByPublicID(ctx, projectID)
|
project, err = s.GetProjectNoAuth(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
return false, nil, err
|
||||||
project, err = s.store.Projects().Get(ctx, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil, Error.Wrap(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return false, nil, Error.Wrap(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.OwnerID != userID {
|
if project.OwnerID != userID {
|
||||||
@ -2961,14 +2984,10 @@ func (s *Service) isProjectOwner(ctx context.Context, userID uuid.UUID, projectI
|
|||||||
// projectID can be either private ID or public ID (project.ID/project.PublicID).
|
// projectID can be either private ID or public ID (project.ID/project.PublicID).
|
||||||
func (s *Service) isProjectMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (_ isProjectMember, err error) {
|
func (s *Service) isProjectMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (_ isProjectMember, err error) {
|
||||||
defer mon.Task()(&ctx)(&err)
|
defer mon.Task()(&ctx)(&err)
|
||||||
var project *Project
|
|
||||||
project, err = s.store.Projects().GetByPublicID(ctx, projectID)
|
project, err := s.GetProjectNoAuth(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tempError := err
|
return isProjectMember{}, err
|
||||||
project, err = s.store.Projects().Get(ctx, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return isProjectMember{}, Error.Wrap(errs.Combine(tempError, err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
memberships, err := s.store.ProjectMembers().GetByMemberID(ctx, userID)
|
memberships, err := s.store.ProjectMembers().GetByMemberID(ctx, userID)
|
||||||
@ -3393,3 +3412,104 @@ func (s *Service) SetUserSettings(ctx context.Context, request UpsertUserSetting
|
|||||||
|
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserProjectInvitations returns a user's pending project member invitations.
|
||||||
|
func (s *Service) GetUserProjectInvitations(ctx context.Context) (_ []ProjectInvitation, err error) {
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
user, err := s.getUserAndAuditLog(ctx, "get project member invitations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, Error.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
invites, err := s.store.ProjectInvitations().GetByEmail(ctx, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Error.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return invites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectInvitationResponse represents a response to a project member invitation.
|
||||||
|
type ProjectInvitationResponse int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ProjectInvitationDecline represents rejection of a project member invitation.
|
||||||
|
ProjectInvitationDecline ProjectInvitationResponse = iota
|
||||||
|
// ProjectInvitationAccept represents acceptance of a project member invitation.
|
||||||
|
ProjectInvitationAccept
|
||||||
|
)
|
||||||
|
|
||||||
|
// RespondToProjectInvitation handles accepting or declining a user's project member invitation.
|
||||||
|
// The given project ID may be the internal or public ID.
|
||||||
|
func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid.UUID, response ProjectInvitationResponse) (err error) {
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
user, err := s.getUserAndAuditLog(ctx, "project member invitation response",
|
||||||
|
zap.String("projectID", projectID.String()),
|
||||||
|
zap.Any("response", response),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response != ProjectInvitationAccept && response != ProjectInvitationDecline {
|
||||||
|
return ErrValidation.New(projInviteResponseInvalidErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
proj, err := s.GetProjectNoAuth(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
projectID = proj.ID
|
||||||
|
|
||||||
|
// log deletion errors that don't affect the outcome
|
||||||
|
deleteWithLog := func() {
|
||||||
|
err := s.store.ProjectInvitations().Delete(ctx, projectID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("error deleting project invitation",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("email", user.Email),
|
||||||
|
zap.String("projectID", projectID.String()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.isProjectMember(ctx, user.ID, projectID)
|
||||||
|
if err == nil {
|
||||||
|
deleteWithLog()
|
||||||
|
if response == ProjectInvitationDecline {
|
||||||
|
return ErrAlreadyMember.New(projInviteAlreadyMemberErrMsg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invite, err := s.store.ProjectInvitations().Get(ctx, projectID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
if !errs.Is(err, sql.ErrNoRows) {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
if response == ProjectInvitationDecline {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
|
||||||
|
deleteWithLog()
|
||||||
|
return ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == ProjectInvitationDecline {
|
||||||
|
return Error.Wrap(s.store.ProjectInvitations().Delete(ctx, projectID, user.Email))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.store.ProjectMembers().Insert(ctx, user.ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWithLog()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -1887,3 +1888,125 @@ func TestServiceGenMethods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectInvitations(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 1,
|
||||||
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
sat := planet.Satellites[0]
|
||||||
|
service := sat.API.Console.Service
|
||||||
|
|
||||||
|
addUser := func(t *testing.T, ctx context.Context) *console.User {
|
||||||
|
user, err := sat.AddUser(ctx, console.CreateUser{
|
||||||
|
FullName: "Test User",
|
||||||
|
Email: fmt.Sprintf("%s@mail.test", testrand.RandAlphaNumeric(16)),
|
||||||
|
}, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserAndCtx := func(t *testing.T) (*console.User, context.Context) {
|
||||||
|
ctx := testcontext.New(t)
|
||||||
|
user := addUser(t, ctx)
|
||||||
|
userCtx, err := sat.UserContext(ctx, user.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return user, userCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
addProject := func(t *testing.T, ctx context.Context) *console.Project {
|
||||||
|
owner := addUser(t, ctx)
|
||||||
|
project, err := sat.AddProject(ctx, owner.ID, "Test Project")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
addInvite := func(t *testing.T, ctx context.Context, project *console.Project, email string, createdAt time.Time) *console.ProjectInvitation {
|
||||||
|
invite, err := sat.DB.Console().ProjectInvitations().Insert(ctx, &console.ProjectInvitation{
|
||||||
|
ProjectID: project.ID,
|
||||||
|
Email: email,
|
||||||
|
InviterID: &project.OwnerID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := sat.DB.Testing().RawDB().ExecContext(ctx,
|
||||||
|
"UPDATE project_invitations SET created_at = $1 WHERE project_id = $2 AND email = $3",
|
||||||
|
createdAt, invite.ProjectID, strings.ToUpper(invite.Email),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
count, err := result.RowsAffected()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, count)
|
||||||
|
|
||||||
|
return invite
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("get invitation", func(t *testing.T) {
|
||||||
|
user, ctx := getUserAndCtx(t)
|
||||||
|
|
||||||
|
invites, err := service.GetUserProjectInvitations(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, invites)
|
||||||
|
|
||||||
|
invite := addInvite(t, ctx, addProject(t, ctx), user.Email, time.Now())
|
||||||
|
invites, err = service.GetUserProjectInvitations(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, invites, 1)
|
||||||
|
require.Equal(t, invite.ProjectID, invites[0].ProjectID)
|
||||||
|
require.Equal(t, invite.Email, invites[0].Email)
|
||||||
|
require.Equal(t, invite.InviterID, invites[0].InviterID)
|
||||||
|
require.WithinDuration(t, invite.CreatedAt, invites[0].CreatedAt, time.Second)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("accept invitation", func(t *testing.T) {
|
||||||
|
user, ctx := getUserAndCtx(t)
|
||||||
|
proj := addProject(t, ctx)
|
||||||
|
|
||||||
|
addInvite(t, ctx, proj, user.Email, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
|
||||||
|
err := service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)
|
||||||
|
require.True(t, console.ErrProjectInviteInvalid.Has(err))
|
||||||
|
|
||||||
|
addInvite(t, ctx, proj, user.Email, time.Now())
|
||||||
|
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept))
|
||||||
|
|
||||||
|
invites, err := service.GetUserProjectInvitations(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, invites)
|
||||||
|
|
||||||
|
memberships, err := sat.DB.Console().ProjectMembers().GetByMemberID(ctx, user.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, memberships, 1)
|
||||||
|
require.Equal(t, proj.ID, memberships[0].ProjectID)
|
||||||
|
|
||||||
|
// Ensure that accepting an invitation for a project you are already a member of doesn't return an error.
|
||||||
|
// This is because the outcome of the operation is the same as if you weren't a member.
|
||||||
|
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept))
|
||||||
|
// Ensure that an error is returned if you're a member of a project whose invitation you decline.
|
||||||
|
err = service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline)
|
||||||
|
require.True(t, console.ErrAlreadyMember.Has(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reject invitation", func(t *testing.T) {
|
||||||
|
user, ctx := getUserAndCtx(t)
|
||||||
|
proj := addProject(t, ctx)
|
||||||
|
|
||||||
|
addInvite(t, ctx, proj, user.Email, time.Now())
|
||||||
|
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline))
|
||||||
|
|
||||||
|
invites, err := service.GetUserProjectInvitations(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, invites)
|
||||||
|
|
||||||
|
memberships, err := sat.DB.Console().ProjectMembers().GetByMemberID(ctx, user.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, memberships)
|
||||||
|
|
||||||
|
// Ensure that declining an invitation for a project you are not a member of doesn't return an error.
|
||||||
|
// This is because the outcome of the operation is the same as if you were a member.
|
||||||
|
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline))
|
||||||
|
// Ensure that an error is returned if you try to accept an invitation that you have already declined or doesn't exist.
|
||||||
|
err = service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)
|
||||||
|
require.True(t, console.ErrProjectInviteInvalid.Has(err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -325,6 +325,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
|
|||||||
# whether to allow purchasing pricing packages
|
# whether to allow purchasing pricing packages
|
||||||
# console.pricing-packages-enabled: false
|
# console.pricing-packages-enabled: false
|
||||||
|
|
||||||
|
# duration that project member invitations are valid for
|
||||||
|
# console.project-invitation-expiration: 168h0m0s
|
||||||
|
|
||||||
# url link to project limit increase request page
|
# url link to project limit increase request page
|
||||||
# console.project-limits-increase-request-url: https://supportdcs.storj.io/hc/en-us/requests/new?ticket_form_id=360000683212
|
# console.project-limits-increase-request-url: https://supportdcs.storj.io/hc/en-us/requests/new?ticket_form_id=360000683212
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user