diff --git a/api/services/auth.go b/api/services/auth.go index ed42934ab1a..f32ea800a64 100644 --- a/api/services/auth.go +++ b/api/services/auth.go @@ -309,10 +309,8 @@ func (s *service) AuthLocalUser(ctx context.Context, req *requests.AuthLocalUser tenantID := "" role := "" - // Populate the tenant and role when the user is associated with a namespace. If the member status is pending, we - // ignore the namespace. if ns, _ := s.store.NamespaceGetPreferred(ctx, user.ID); ns != nil && ns.TenantID != "" { - if m, _ := ns.FindMember(user.ID); m.Status != models.MemberStatusPending { + if m, _ := ns.FindMember(user.ID); m != nil { tenantID = ns.TenantID role = m.Role.String() } @@ -393,10 +391,8 @@ func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserT return nil, NewErrNamespaceMemberNotFound(user.ID, nil) } - if member.Status != models.MemberStatusPending { - tenantID = namespace.TenantID - role = member.Role.String() - } + tenantID = namespace.TenantID + role = member.Role.String() default: namespace, err := s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) if err != nil { @@ -408,10 +404,6 @@ func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserT return nil, NewErrNamespaceMemberNotFound(user.ID, nil) } - if member.Status == models.MemberStatusPending { - return nil, NewErrNamespaceMemberNotFound(user.ID, nil) - } - tenantID = namespace.TenantID role = member.Role.String() diff --git a/api/services/auth_test.go b/api/services/auth_test.go index 90ad3112183..8f818ec4f66 100644 --- a/api/services/auth_test.go +++ b/api/services/auth_test.go @@ -1890,134 +1890,6 @@ func TestService_AuthLocalUser(t *testing.T) { err: nil, }, }, - { - description: "succeeds to authenticate with a namespace (and member status 'pending')", - sourceIP: "127.0.0.1", - req: &requests.AuthLocalUser{ - Identifier: "john_doe", - Password: "secret", - }, - requiredMocks: func() { - user := &models.User{ - ID: "65fdd16b5f62f93184ec8a39", - Origin: models.UserOriginLocal, - Status: models.UserStatusConfirmed, - LastLogin: now, - MFA: models.UserMFA{ - Enabled: false, - }, - UserData: models.UserData{ - Username: "john_doe", - Email: "john.doe@test.com", - Name: "john doe", - }, - Password: models.UserPassword{ - Hash: "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi", - }, - Preferences: models.UserPreferences{ - PreferredNamespace: "00000000-0000-4000-0000-000000000000", - AuthMethods: []models.UserAuthMethod{models.UserAuthMethodLocal}, - }, - } - updatedUser := &models.User{ - ID: "65fdd16b5f62f93184ec8a39", - Origin: models.UserOriginLocal, - Status: models.UserStatusConfirmed, - LastLogin: now, - MFA: models.UserMFA{ - Enabled: false, - }, - UserData: models.UserData{ - Username: "john_doe", - Email: "john.doe@test.com", - Name: "john doe", - }, - Password: models.UserPassword{ - Hash: "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi", - }, - Preferences: models.UserPreferences{ - PreferredNamespace: "", - AuthMethods: []models.UserAuthMethod{models.UserAuthMethodLocal}, - }, - } - - mock. - On("SystemGet", ctx). - Return( - &models.System{ - Authentication: &models.SystemAuthentication{ - Local: &models.SystemAuthenticationLocal{ - Enabled: true, - }, - }, - }, - nil, - ). - Once() - mock. - On("UserResolve", ctx, store.UserUsernameResolver, "john_doe"). - Return(user, nil). - Once() - cacheMock. - On("HasAccountLockout", ctx, "127.0.0.1", "65fdd16b5f62f93184ec8a39"). - Return(int64(0), 0, nil). - Once() - hashMock. - On("CompareWith", "secret", "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi"). - Return(true). - Once() - cacheMock. - On("ResetLoginAttempts", ctx, "127.0.0.1", "65fdd16b5f62f93184ec8a39"). - Return(nil). - Once() - - ns := &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Members: []models.Member{ - { - ID: "65fdd16b5f62f93184ec8a39", - Role: "owner", - Status: models.MemberStatusPending, - }, - }, - } - - mock. - On("NamespaceGetPreferred", ctx, "65fdd16b5f62f93184ec8a39"). - Return(ns, nil). - Once() - - clockMock := new(clockmock.Clock) - clock.DefaultBackend = clockMock - clockMock.On("Now").Return(now) - - cacheMock. - On("Set", ctx, "token_65fdd16b5f62f93184ec8a39", testifymock.Anything, time.Hour*72). - Return(nil). - Once() - - mock. - On("UserUpdate", ctx, updatedUser). - Return(nil). - Once() - }, - expected: Expected{ - res: &models.UserAuthResponse{ - ID: "65fdd16b5f62f93184ec8a39", - Origin: models.UserOriginLocal.String(), - AuthMethods: []models.UserAuthMethod{models.UserAuthMethodLocal}, - Name: "john doe", - User: "john_doe", - Email: "john.doe@test.com", - Tenant: "", - Role: "", - Token: "must ignore", - }, - lockout: 0, - mfaToken: "", - err: nil, - }, - }, { description: "succeeds to authenticate with a namespace (and empty preferred namespace)", sourceIP: "127.0.0.1", @@ -2391,57 +2263,6 @@ func TestCreateUserToken(t *testing.T) { err: NewErrNamespaceMemberNotFound("000000000000000000000000", nil), }, }, - { - description: "[with-tenant] fails when user membership is pending", - req: &requests.CreateUserToken{UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000"}, - requiredMocks: func(ctx context.Context) { - storeMock. - On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). - Return( - &models.User{ - ID: "000000000000000000000000", - Status: models.UserStatusConfirmed, - LastLogin: now, - MFA: models.UserMFA{ - Enabled: false, - }, - UserData: models.UserData{ - Username: "john_doe", - Email: "john.doe@test.com", - Name: "john doe", - }, - Password: models.UserPassword{ - Hash: "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi", - }, - Preferences: models.UserPreferences{ - PreferredNamespace: "", - }, - }, - nil, - ). - Once() - storeMock. - On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return( - &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Members: []models.Member{ - { - ID: "000000000000000000000000", - Role: "administrator", - Status: models.MemberStatusPending, - }, - }, - }, - nil, - ). - Once() - }, - expected: Expected{ - res: nil, - err: NewErrNamespaceMemberNotFound("000000000000000000000000", nil), - }, - }, { description: "[with-tenant] succeeds", req: &requests.CreateUserToken{UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000"}, @@ -2496,9 +2317,8 @@ func TestCreateUserToken(t *testing.T) { TenantID: "00000000-0000-4000-0000-000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: "owner", - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: "owner", }, }, }, @@ -2566,9 +2386,8 @@ func TestCreateUserToken(t *testing.T) { TenantID: "00000000-0000-4000-0000-000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: "owner", - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: "owner", }, }, }, diff --git a/api/services/member.go b/api/services/member.go index ba721507c20..4d5e60dd039 100644 --- a/api/services/member.go +++ b/api/services/member.go @@ -23,10 +23,10 @@ type MemberService interface { // AddNamespaceMember adds a member to a namespace. // - // In cloud environments, the member is assigned a [MemberStatusPending] status until they accept the invite via + // In cloud environments, a membership invitation is created with pending status until they accept the invite via // an invitation email. If the target user does not exist, the email will redirect them to the registration page, - // and the invite can be accepted after finishing. In community and enterprise environments, the status is set to - // [MemberStatusAccepted] without sending an email. + // and the invite can be accepted after finishing. In community and enterprise environments, the member is added + // directly to the namespace without sending an email. // // The role assigned to the new member must not grant more authority than the user adding them (e.g., // an administrator cannot add a member with a higher role such as an owner). Owners cannot be created. @@ -35,11 +35,21 @@ type MemberService interface { AddNamespaceMember(ctx context.Context, req *requests.NamespaceAddMember) (*models.Namespace, error) // UpdateNamespaceMember updates a member with the specified ID in the specified namespace. The member's role cannot - // have more authority than the user who is updating the member; owners cannot be created. It returns an error, if any. + // have more authority than the user who is updating the member; owners cannot be created. + // + // In cloud environments, if the target is not found in the namespace members, it will check for pending invitations + // in the membership_invitations collection and update the invitation instead. + // + // It returns an error, if any. UpdateNamespaceMember(ctx context.Context, req *requests.NamespaceUpdateMember) error // RemoveNamespaceMember removes a specified member from a namespace. The action must be performed by a user with higher - // authority than the target member. Owners cannot be removed. Returns the updated namespace and an error, if any. + // authority than the target member. Owners cannot be removed. + // + // In cloud environments, if the target is not found in the namespace members, it will check for pending invitations + // in the membership_invitations collection and cancel the invitation by setting its status to 'cancelled'. + // + // Returns the updated namespace and an error, if any. RemoveNamespaceMember(ctx context.Context, req *requests.NamespaceRemoveMember) (*models.Namespace, error) // LeaveNamespace allows an authenticated user to remove themselves from a namespace. Owners cannot leave a namespace. @@ -85,54 +95,82 @@ func (s *service) AddNamespaceMember(ctx context.Context, req *requests.Namespac } } - // In cloud instances, if a member exists and their status is pending and the expiration date is reached, - // we resend the invite instead of adding the member. - // In community and enterprise instances, a "duplicate" error is always returned, - // since the member will never be in a pending status. - // Otherwise, add the member "from scratch" - if m, ok := namespace.FindMember(passiveUser.ID); ok { - now := clock.Now() - - if !envs.IsCloud() || (m.Status != models.MemberStatusPending || !m.ExpiresAt.Before(now)) { - return nil, NewErrNamespaceMemberDuplicated(passiveUser.ID, nil) - } + if _, ok := namespace.FindMember(passiveUser.ID); ok { + return nil, NewErrNamespaceMemberDuplicated(passiveUser.ID, nil) + } - if err := s.store.WithTransaction(ctx, s.resendMemberInvite(m, req)); err != nil { - return nil, err - } + var callback store.TransactionCb + if !envs.IsCloud() { + callback = s.addMember(namespace, passiveUser.ID, req) } else { - if err := s.store.WithTransaction(ctx, s.addMember(passiveUser.ID, req)); err != nil { + invitation, err := s.store.MembershipInvitationResolve(ctx, req.TenantID, passiveUser.ID) + if err != nil && !errors.Is(err, store.ErrNoDocuments) { return nil, err } + + if invitation == nil || !invitation.IsPending() { + callback = s.addMember(namespace, passiveUser.ID, req) + } else { + if !invitation.IsExpired() { + return nil, NewErrNamespaceMemberDuplicated(passiveUser.ID, nil) + } + + callback = s.resendMembershipInvite(invitation, req) + } } - return s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) + if err := s.store.WithTransaction(ctx, callback); err != nil { + return nil, err + } + + n, err := s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) + if err != nil { + return nil, err + } + + return n, nil } -// addMember returns a transaction callback that adds a member and sends an invite if the instance is cloud. -func (s *service) addMember(memberID string, req *requests.NamespaceAddMember) store.TransactionCb { +// addMember returns a transaction callback that adds a member to a namespace. +// +// In all environments, it creates a membership_invitation record for audit purposes: +// - Cloud: Creates pending invitation with expiration and sends email +// - Community/Enterprise: Creates accepted invitation and adds member directly to namespace +func (s *service) addMember(namespace *models.Namespace, memberID string, req *requests.NamespaceAddMember) store.TransactionCb { return func(ctx context.Context) error { - member := &models.Member{ - ID: memberID, - AddedAt: clock.Now(), - Role: req.MemberRole, + now := clock.Now() + + invitation := &models.MembershipInvitation{ + TenantID: req.TenantID, + UserID: memberID, + InvitedBy: namespace.Owner, + Role: req.MemberRole, + CreatedAt: now, + UpdatedAt: now, + StatusUpdatedAt: now, + Invitations: 1, } - // In cloud instances, the member must accept the invite before enter in the namespace. if envs.IsCloud() { - member.Status = models.MemberStatusPending - member.ExpiresAt = member.AddedAt.Add(7 * (24 * time.Hour)) - } else { - member.Status = models.MemberStatusAccepted - member.ExpiresAt = time.Time{} - } + expiresAt := now.Add(7 * (24 * time.Hour)) + invitation.Status = models.MembershipInvitationStatusPending + invitation.ExpiresAt = &expiresAt + if err := s.store.MembershipInvitationCreate(ctx, invitation); err != nil { + return err + } - if err := s.store.NamespaceCreateMembership(ctx, req.TenantID, member); err != nil { - return err - } + if err := s.client.InviteMember(ctx, req.TenantID, memberID, req.FowardedHost); err != nil { + return err + } + } else { + invitation.Status = models.MembershipInvitationStatusAccepted + invitation.ExpiresAt = nil + if err := s.store.MembershipInvitationCreate(ctx, invitation); err != nil { + return err + } - if envs.IsCloud() { - if err := s.client.InviteMember(ctx, req.TenantID, member.ID, req.FowardedHost); err != nil { + member := &models.Member{ID: memberID, AddedAt: now, Role: req.MemberRole} + if err := s.store.NamespaceCreateMembership(ctx, req.TenantID, member); err != nil { return err } } @@ -141,18 +179,27 @@ func (s *service) addMember(memberID string, req *requests.NamespaceAddMember) s } } -// resendMemberInvite returns a transaction callback that resends an invitation to the member with the -// specified ID. -func (s *service) resendMemberInvite(member *models.Member, req *requests.NamespaceAddMember) store.TransactionCb { +// resendMembershipInvite returns a transaction callback that resends a membership invitation. +// +// This function updates an existing invitation to pending status, extends the expiration date, +// increments the invitation counter, and sends a new invitation email (cloud only). +func (s *service) resendMembershipInvite(invitation *models.MembershipInvitation, req *requests.NamespaceAddMember) store.TransactionCb { return func(ctx context.Context) error { - member.ExpiresAt = clock.Now().Add(7 * (24 * time.Hour)) - member.Role = req.MemberRole + now := clock.Now() + + expiresAt := now.Add(7 * (24 * time.Hour)) + invitation.Status = models.MembershipInvitationStatusPending + invitation.Role = req.MemberRole + invitation.ExpiresAt = &expiresAt + invitation.UpdatedAt = now + invitation.StatusUpdatedAt = now + invitation.Invitations++ - if err := s.store.NamespaceUpdateMembership(ctx, req.TenantID, member); err != nil { + if err := s.store.MembershipInvitationUpdate(ctx, invitation); err != nil { return err } - return s.client.InviteMember(ctx, req.TenantID, member.ID, req.FowardedHost) + return s.client.InviteMember(ctx, req.TenantID, invitation.UserID, req.FowardedHost) } } @@ -174,7 +221,28 @@ func (s *service) UpdateNamespaceMember(ctx context.Context, req *requests.Names member, ok := namespace.FindMember(req.MemberID) if !ok { - return NewErrNamespaceMemberNotFound(req.MemberID, err) + if !envs.IsCloud() { + return NewErrNamespaceMemberNotFound(req.MemberID, err) + } + + invitation, err := s.store.MembershipInvitationResolve(ctx, req.TenantID, req.MemberID) + if err != nil { + return NewErrNamespaceMemberNotFound(req.MemberID, err) + } + + if req.MemberRole != authorizer.RoleInvalid { + if !active.Role.HasAuthority(req.MemberRole) { + return NewErrRoleInvalid() + } + + invitation.Role = req.MemberRole + invitation.UpdatedAt = clock.Now() + if err := s.store.MembershipInvitationUpdate(ctx, invitation); err != nil { + return err + } + } + + return nil } if req.MemberRole != authorizer.RoleInvalid { @@ -212,7 +280,28 @@ func (s *service) RemoveNamespaceMember(ctx context.Context, req *requests.Names passive, ok := namespace.FindMember(req.MemberID) if !ok { - return nil, NewErrNamespaceMemberNotFound(req.MemberID, err) + if !envs.IsCloud() { + return nil, NewErrNamespaceMemberNotFound(req.MemberID, err) + } + + invitation, err := s.store.MembershipInvitationResolve(ctx, req.TenantID, req.MemberID) + if err != nil { + return nil, NewErrNamespaceMemberNotFound(req.MemberID, err) + } + + if !active.Role.HasAuthority(invitation.Role) { + return nil, NewErrRoleInvalid() + } + + now := clock.Now() + invitation.Status = models.MembershipInvitationStatusCancelled + invitation.UpdatedAt = now + invitation.StatusUpdatedAt = now + if err := s.store.MembershipInvitationUpdate(ctx, invitation); err != nil { + return nil, err + } + + return s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) } if !active.Role.HasAuthority(passive.Role) { diff --git a/api/services/member_test.go b/api/services/member_test.go index 0d97eb65cfe..d2d3e3a35b6 100644 --- a/api/services/member_test.go +++ b/api/services/member_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/mock" ) -func TestAddNamespaceMember(t *testing.T) { +func TestService_AddNamespaceMember(t *testing.T) { type Expected struct { namespace *models.Namespace err error @@ -44,7 +44,7 @@ func TestAddNamespaceMember(t *testing.T) { expected Expected }{ { - description: "fails when the namespace was not found", + description: "[community|enterprise|cloud] fails when the namespace was not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -64,7 +64,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member was not found", + description: "[community|enterprise|cloud] fails when the active member was not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -93,7 +93,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member is not on the namespace", + description: "[community|enterprise|cloud] fails when the active member is not on the namespace", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -125,7 +125,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the passive role's is owner", + description: "[community|enterprise|cloud] fails when the passive role's is owner", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -142,9 +142,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOperator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOperator, }, }, }, nil). @@ -163,7 +162,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member's role cannot act over passive member's role", + description: "[community|enterprise|cloud] fails when the active member's role cannot act over passive member's role", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -180,9 +179,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOperator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOperator, }, }, }, nil). @@ -201,7 +199,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when passive member was not found", + description: "[community|enterprise] fails when passive member was not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -218,9 +216,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -247,7 +244,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the member is duplicated without 'pending' status and expiration date not reached", + description: "[community|enterprise|cloud] fails when the member is duplicated", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -264,14 +261,12 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, }, }, }, nil). @@ -290,10 +285,6 @@ func TestAddNamespaceMember(t *testing.T) { UserData: models.UserData{Username: "john_doe"}, }, nil). Once() - envMock. - On("Get", "SHELLHUB_CLOUD"). - Return("false"). - Once() }, expected: Expected{ namespace: nil, @@ -301,7 +292,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "[cloud] fails when the member is duplicated without 'pending' status and expiration date not reached", + description: "[cloud] fails when the member has pending invitation not expired", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -318,14 +309,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -348,6 +333,18 @@ func TestAddNamespaceMember(t *testing.T) { On("Get", "SHELLHUB_CLOUD"). Return("true"). Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return( + &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Status: models.MembershipInvitationStatusPending, + ExpiresAt: &[]time.Time{time.Now().Add(14 * (24 * time.Hour))}[0], + }, + nil, + ). + Once() }, expected: Expected{ namespace: nil, @@ -355,7 +352,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "[cloud] fails when the member is duplicated with 'pending' status and expiration date not reached", + description: "[community|enterprise] fails when cannot add the member", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -372,15 +369,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, - ExpiresAt: time.Now().Add(7 * (24 * time.Hour)), + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -401,16 +391,20 @@ func TestAddNamespaceMember(t *testing.T) { Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("true"). + Return("false"). + Once() + storeMock. + On("WithTransaction", ctx, mock.Anything). + Return(errors.New("error")). Once() }, expected: Expected{ namespace: nil, - err: NewErrNamespaceMemberDuplicated("000000000000000000000001", nil), + err: errors.New("error"), }, }, { - description: "[cloud] succeeds to resend the invite", + description: "[community|enterprise] succeeds", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -427,15 +421,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, - ExpiresAt: time.Date(2023, 0o1, 0o1, 12, 0o0, 0o0, 0o0, time.UTC), + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -456,7 +443,7 @@ func TestAddNamespaceMember(t *testing.T) { Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("true"). + Return("false"). Once() storeMock. On("WithTransaction", ctx, mock.Anything). @@ -464,26 +451,21 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return( - &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Name: "namespace", - Owner: "000000000000000000000000", - Members: []models.Member{ - { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }, + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + { + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, - nil, - ). + }, nil). Once() }, expected: Expected{ @@ -493,14 +475,12 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, }, @@ -508,7 +488,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "[cloud] succeeds to create the member when not found", + description: "[cloud] succeeds", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -525,9 +505,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -541,15 +520,18 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("UserResolve", ctx, store.UserEmailResolver, "john.doe@test.com"). - Return(nil, store.ErrNoDocuments). + Return(&models.User{ + ID: "000000000000000000000001", + UserData: models.UserData{Username: "john_doe"}, + }, nil). Once() envMock. On("Get", "SHELLHUB_CLOUD"). Return("true"). Once() storeMock. - On("UserInvitationsUpsert", ctx, "john.doe@test.com"). - Return("000000000000000000000001", nil). + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(nil, store.ErrNoDocuments). Once() storeMock. On("WithTransaction", ctx, mock.Anything). @@ -557,26 +539,21 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return( - &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Name: "namespace", - Owner: "000000000000000000000000", - Members: []models.Member{ - { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }, + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + { + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, - nil, - ). + }, nil). Once() }, expected: Expected{ @@ -586,14 +563,12 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, }, @@ -601,7 +576,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when cannot add the member", + description: "[cloud] succeeds to resend the invite", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -618,9 +593,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -641,20 +615,53 @@ func TestAddNamespaceMember(t *testing.T) { Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("false"). + Return("true"). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(&models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Status: models.MembershipInvitationStatusExpired, + ExpiresAt: &[]time.Time{time.Date(2023, 0o1, 0o1, 12, 0o0, 0o0, 0o0, time.UTC)}[0], + }, nil). Once() storeMock. On("WithTransaction", ctx, mock.Anything). - Return(errors.New("error")). + Return(nil). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). Once() }, expected: Expected{ - namespace: nil, - err: errors.New("error"), + namespace: &models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, + err: nil, }, }, { - description: "succeeds", + description: "[cloud] succeeds to create the user when not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -671,9 +678,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -687,14 +693,23 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("UserResolve", ctx, store.UserEmailResolver, "john.doe@test.com"). - Return(&models.User{ - ID: "000000000000000000000001", - UserData: models.UserData{Username: "john_doe"}, - }, nil). + Return(nil, store.ErrNoDocuments). Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("false"). + Return("true"). + Once() + storeMock. + On("UserInvitationsUpsert", ctx, "john.doe@test.com"). + Return("000000000000000000000001", nil). + Once() + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(nil, store.ErrNoDocuments). Once() storeMock. On("WithTransaction", ctx, mock.Anything). @@ -708,14 +723,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000000", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -728,14 +737,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000000", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, @@ -771,13 +774,15 @@ func TestService_addMember(t *testing.T) { cases := []struct { description string + namespace *models.Namespace memberID string req *requests.NamespaceAddMember requiredMocks func(context.Context) expected error }{ { - description: "fails cannot add the member", + description: "[community|enterprise] fails when cannot create membership invitation", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -792,14 +797,21 @@ func TestService_addMember(t *testing.T) { Return("false"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusAccepted, AddedAt: now, ExpiresAt: time.Time{}}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusAccepted && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt == nil + })). Return(errors.New("error")). Once() }, expected: errors.New("error"), }, { - description: "succeeds", + description: "[community|enterprise] fails when cannot create namespace membership", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -814,18 +826,58 @@ func TestService_addMember(t *testing.T) { Return("false"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusAccepted, AddedAt: now, ExpiresAt: time.Time{}}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusAccepted && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt == nil + })). Return(nil). Once() + storeMock. + On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, AddedAt: now}). + Return(errors.New("error")). + Once() + }, + expected: errors.New("error"), + }, + { + description: "[community|enterprise] succeeds", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, + memberID: "000000000000000000000000", + req: &requests.NamespaceAddMember{ + FowardedHost: "localhost", + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberEmail: "john.doe@test.com", + MemberRole: authorizer.RoleObserver, + }, + requiredMocks: func(ctx context.Context) { envMock. On("Get", "SHELLHUB_CLOUD"). Return("false"). Once() + storeMock. + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusAccepted && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt == nil + })). + Return(nil). + Once() + storeMock. + On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, AddedAt: now}). + Return(nil). + Once() }, expected: nil, }, { - description: "[cloud] fails cannot add the member", + description: "[cloud] fails when cannot create membership invitation", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -840,7 +892,13 @@ func TestService_addMember(t *testing.T) { Return("true"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now, ExpiresAt: now.Add(7 * (24 * time.Hour))}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil + })). Return(errors.New("error")). Once() }, @@ -848,6 +906,7 @@ func TestService_addMember(t *testing.T) { }, { description: "[cloud] fails cannot send the invite", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -862,13 +921,15 @@ func TestService_addMember(t *testing.T) { Return("true"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now, ExpiresAt: now.Add(7 * (24 * time.Hour))}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil + })). Return(nil). Once() - envMock. - On("Get", "SHELLHUB_CLOUD"). - Return("true"). - Once() clientMock. On("InviteMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000000", "localhost"). Return(errors.New("error")). @@ -878,6 +939,7 @@ func TestService_addMember(t *testing.T) { }, { description: "[cloud] succeeds", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -892,13 +954,15 @@ func TestService_addMember(t *testing.T) { Return("true"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now, ExpiresAt: now.Add(7 * (24 * time.Hour))}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil + })). Return(nil). Once() - envMock. - On("Get", "SHELLHUB_CLOUD"). - Return("true"). - Once() clientMock. On("InviteMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000000", "localhost"). Return(nil). @@ -915,7 +979,7 @@ func TestService_addMember(t *testing.T) { ctx := context.Background() tc.requiredMocks(ctx) - cb := s.addMember(tc.memberID, tc.req) + cb := s.addMember(tc.namespace, tc.memberID, tc.req) assert.Equal(tt, tc.expected, cb(ctx)) storeMock.AssertExpectations(tt) @@ -924,7 +988,7 @@ func TestService_addMember(t *testing.T) { } } -func TestService_resendMemberInvite(t *testing.T) { +func TestService_resendMembershipInvite(t *testing.T) { envMock = new(envmock.Backend) storeMock := new(storemock.Store) clockMock := new(clockmock.Clock) @@ -937,19 +1001,21 @@ func TestService_resendMemberInvite(t *testing.T) { cases := []struct { description string - member *models.Member + invitation *models.MembershipInvitation req *requests.NamespaceAddMember requiredMocks func(context.Context) expected error }{ { - description: "fails cannot update the member", - member: &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(-1 * (24 * time.Hour)), - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, + description: "[cloud] fails when cannot update the invitation", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000000", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusExpired, + CreatedAt: now.Add(-7 * (24 * time.Hour)), + ExpiresAt: &[]time.Time{now.Add(-1 * (24 * time.Hour))}[0], + Invitations: 1, }, req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -958,26 +1024,29 @@ func TestService_resendMemberInvite(t *testing.T) { }, requiredMocks: func(ctx context.Context) { storeMock. - On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(7 * (24 * time.Hour)), - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }). + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil && + invitation.Invitations == 2 + })). Return(errors.New("error")). Once() }, expected: errors.New("error"), }, { - description: "fails when cannot send the invite", - member: &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(-1 * (24 * time.Hour)), - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, + description: "[cloud] fails when cannot send the invite", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000000", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusExpired, + CreatedAt: now.Add(-7 * (24 * time.Hour)), + ExpiresAt: &[]time.Time{now.Add(-1 * (24 * time.Hour))}[0], + Invitations: 1, }, req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -986,13 +1055,14 @@ func TestService_resendMemberInvite(t *testing.T) { }, requiredMocks: func(ctx context.Context) { storeMock. - On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(7 * (24 * time.Hour)), - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }). + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil && + invitation.Invitations == 2 + })). Return(nil). Once() clientMock. @@ -1003,13 +1073,15 @@ func TestService_resendMemberInvite(t *testing.T) { expected: errors.New("error"), }, { - description: "succeeds", - member: &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(-1 * (24 * time.Hour)), - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, + description: "[cloud] succeeds", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000000", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusExpired, + CreatedAt: now.Add(-7 * (24 * time.Hour)), + ExpiresAt: &[]time.Time{now.Add(-1 * (24 * time.Hour))}[0], + Invitations: 1, }, req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -1018,13 +1090,14 @@ func TestService_resendMemberInvite(t *testing.T) { }, requiredMocks: func(ctx context.Context) { storeMock. - On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(7 * (24 * time.Hour)), - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }). + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil && + invitation.Invitations == 2 + })). Return(nil). Once() clientMock. @@ -1043,7 +1116,7 @@ func TestService_resendMemberInvite(t *testing.T) { ctx := context.Background() tc.requiredMocks(ctx) - cb := s.resendMemberInvite(tc.member, tc.req) + cb := s.resendMembershipInvite(tc.invitation, tc.req) assert.Equal(tt, tc.expected, cb(ctx)) storeMock.AssertExpectations(tt) @@ -1052,9 +1125,12 @@ func TestService_resendMemberInvite(t *testing.T) { } } -func TestUpdateNamespaceMember(t *testing.T) { +func TestService_UpdateNamespaceMember(t *testing.T) { + envMock := new(envmock.Backend) storeMock := new(storemock.Store) + envs.DefaultBackend = envMock + cases := []struct { description string req *requests.NamespaceUpdateMember @@ -1062,7 +1138,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected error }{ { - description: "fails when the namespace was not found", + description: "[community|enterprise|cloud] fails when the namespace was not found", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1078,7 +1154,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrNamespaceNotFound("00000000-0000-4000-0000-000000000000", ErrNamespaceNotFound), }, { - description: "fails when the active member was not found", + description: "[community|enterprise|cloud] fails when the active member was not found", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1103,7 +1179,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrUserNotFound("000000000000000000000000", ErrUserNotFound), }, { - description: "fails when the active member is not on the namespace", + description: "[community|enterprise|cloud] fails when the active member is not on the namespace", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1131,7 +1207,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrNamespaceMemberNotFound("000000000000000000000000", nil), }, { - description: "fails when the passive member is not on the namespace", + description: "[community|enterprise] fails when the passive member is not on the namespace", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1139,6 +1215,10 @@ func TestUpdateNamespaceMember(t *testing.T) { MemberRole: authorizer.RoleObserver, }, requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("false"). + Once() storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). Return(&models.Namespace{ @@ -1164,7 +1244,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrNamespaceMemberNotFound("000000000000000000000001", nil), }, { - description: "fails when the passive role's is owner", + description: "[community|enterprise|cloud] fails when the passive role's is owner", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1201,7 +1281,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrRoleInvalid(), }, { - description: "fails when the active member's role cannot act over passive member's role", + description: "[community|enterprise|cloud] fails when the active member's role cannot act over passive member's role", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1238,7 +1318,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrRoleInvalid(), }, { - description: "fails when cannot update the member", + description: "[community|enterprise|cloud] fails when cannot update the member", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1273,66 +1353,261 @@ func TestUpdateNamespaceMember(t *testing.T) { Once() storeMock. On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000001", Role: authorizer.RoleAdministrator}). - Return(nil). + Return(errors.New("error")). Once() }, - expected: nil, + expected: errors.New("error"), }, - } - - s := NewService(store.Store(storeMock), privateKey, publicKey, storecache.NewNullCache(), clientMock) - - for _, tc := range cases { - t.Run(tc.description, func(t *testing.T) { - ctx := context.TODO() - tc.requiredMocks(ctx) - err := s.UpdateNamespaceMember(ctx, tc.req) - assert.Equal(t, tc.expected, err) - }) - } - storeMock.AssertExpectations(t) -} - -func TestRemoveNamespaceMember(t *testing.T) { - type Expected struct { - namespace *models.Namespace - err error - } - - storeMock := new(storemock.Store) - - cases := []struct { - description string - req *requests.NamespaceRemoveMember - requiredMocks func(context.Context) - expected Expected - }{ { - description: "fails when the namespace was not found", - req: &requests.NamespaceRemoveMember{ - UserID: "000000000000000000000000", - TenantID: "00000000-0000-4000-0000-000000000000", - MemberID: "000000000000000000000001", + description: "[community|enterprise|cloud] succeeds", + req: &requests.NamespaceUpdateMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + MemberRole: authorizer.RoleAdministrator, }, requiredMocks: func(ctx context.Context) { storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return(nil, ErrNamespaceNotFound). - Once() - }, - expected: Expected{ - namespace: nil, - err: NewErrNamespaceNotFound("00000000-0000-4000-0000-000000000000", ErrNamespaceNotFound), - }, - }, - { - description: "fails when the active member was not found", - req: &requests.NamespaceRemoveMember{ - UserID: "000000000000000000000000", - TenantID: "00000000-0000-4000-0000-000000000000", - MemberID: "000000000000000000000001", - }, - requiredMocks: func(ctx context.Context) { + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + { + ID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000001", Role: authorizer.RoleAdministrator}). + Return(nil). + Once() + }, + expected: nil, + }, + { + description: "[cloud] fails when passive member not found and invitation not found", + req: &requests.NamespaceUpdateMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + MemberRole: authorizer.RoleObserver, + }, + requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(nil, store.ErrNoDocuments). + Once() + }, + expected: NewErrNamespaceMemberNotFound("000000000000000000000001", store.ErrNoDocuments), + }, + { + description: "[cloud] fails to update invitation", + req: &requests.NamespaceUpdateMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + MemberRole: authorizer.RoleObserver, + }, + requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(&models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + }, nil). + Once() + storeMock. + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000001" && + invitation.Role == authorizer.RoleObserver + })). + Return(errors.New("error")). + Once() + }, + expected: errors.New("error"), + }, + { + description: "[cloud] succeeds to update invitation", + req: &requests.NamespaceUpdateMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + MemberRole: authorizer.RoleObserver, + }, + requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(&models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + }, nil). + Once() + storeMock. + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000001" && + invitation.Role == authorizer.RoleObserver + })). + Return(nil). + Once() + }, + expected: nil, + }, + } + + s := NewService(store.Store(storeMock), privateKey, publicKey, storecache.NewNullCache(), clientMock) + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + ctx := context.TODO() + tc.requiredMocks(ctx) + err := s.UpdateNamespaceMember(ctx, tc.req) + assert.Equal(t, tc.expected, err) + }) + } + + storeMock.AssertExpectations(t) + envMock.AssertExpectations(t) +} + +func TestService_RemoveNamespaceMember(t *testing.T) { + type Expected struct { + namespace *models.Namespace + err error + } + + envMock := new(envmock.Backend) + storeMock := new(storemock.Store) + + envs.DefaultBackend = envMock + + cases := []struct { + description string + req *requests.NamespaceRemoveMember + requiredMocks func(context.Context) + expected Expected + }{ + { + description: "[community|enterprise|cloud] fails when the namespace was not found", + req: &requests.NamespaceRemoveMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + }, + requiredMocks: func(ctx context.Context) { + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(nil, ErrNamespaceNotFound). + Once() + }, + expected: Expected{ + namespace: nil, + err: NewErrNamespaceNotFound("00000000-0000-4000-0000-000000000000", ErrNamespaceNotFound), + }, + }, + { + description: "[community|enterprise|cloud] fails when the active member was not found", + req: &requests.NamespaceRemoveMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + }, + requiredMocks: func(ctx context.Context) { storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). Return(&models.Namespace{ @@ -1353,7 +1628,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member is not on the namespace", + description: "[community|enterprise|cloud] fails when the active member is not on the namespace", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1383,13 +1658,17 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the passive member is not on the namespace", + description: "[community|enterprise] fails when the passive member is not on the namespace", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", MemberID: "000000000000000000000001", }, requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("false"). + Once() storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). Return(&models.Namespace{ @@ -1418,7 +1697,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member's role cannot act over passive member's role", + description: "[community|enterprise|cloud] fails when the active member's role cannot act over passive member's role", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1457,7 +1736,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when cannot remove the member", + description: "[community|enterprise|cloud] fails when cannot remove the member", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1500,7 +1779,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "succeeds", + description: "[community|enterprise|cloud] succeeds", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1566,6 +1845,185 @@ func TestRemoveNamespaceMember(t *testing.T) { err: nil, }, }, + { + description: "[cloud] fails when passive member not found and invitation not found", + req: &requests.NamespaceRemoveMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + }, + requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(nil, store.ErrNoDocuments). + Once() + }, + expected: Expected{ + namespace: nil, + err: NewErrNamespaceMemberNotFound("000000000000000000000001", store.ErrNoDocuments), + }, + }, + { + description: "[cloud] fails to update invitation", + req: &requests.NamespaceRemoveMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + }, + requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(&models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + }, nil). + Once() + storeMock. + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000001" && + invitation.Status == models.MembershipInvitationStatusCancelled + })). + Return(errors.New("error")). + Once() + }, + expected: Expected{ + namespace: nil, + err: errors.New("error"), + }, + }, + { + description: "[cloud] succeeds to update invitation", + req: &requests.NamespaceRemoveMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + }, + requiredMocks: func(ctx context.Context) { + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(&models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + }, nil). + Once() + storeMock. + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000001" && + invitation.Status == models.MembershipInvitationStatusCancelled + })). + Return(nil). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). + Once() + }, + expected: Expected{ + namespace: &models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, + err: nil, + }, + }, } s := NewService(store.Store(storeMock), privateKey, publicKey, storecache.NewNullCache(), clientMock) @@ -1580,6 +2038,7 @@ func TestRemoveNamespaceMember(t *testing.T) { } storeMock.AssertExpectations(t) + envMock.AssertExpectations(t) } func TestService_LeaveNamespace(t *testing.T) { diff --git a/api/services/namespace.go b/api/services/namespace.go index cad8bfe2107..a040de29759 100644 --- a/api/services/namespace.go +++ b/api/services/namespace.go @@ -60,7 +60,6 @@ func (s *service) CreateNamespace(ctx context.Context, req *requests.NamespaceCr { ID: user.ID, Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: clock.Now(), }, }, diff --git a/api/services/namespace_test.go b/api/services/namespace_test.go index 8069959bca0..1af67dae757 100644 --- a/api/services/namespace_test.go +++ b/api/services/namespace_test.go @@ -14,6 +14,8 @@ import ( storecache "github.com/shellhub-io/shellhub/pkg/cache" "github.com/shellhub-io/shellhub/pkg/clock" clockmock "github.com/shellhub-io/shellhub/pkg/clock/mocks" + "github.com/shellhub-io/shellhub/pkg/envs" + envmock "github.com/shellhub-io/shellhub/pkg/envs/mocks" "github.com/shellhub-io/shellhub/pkg/models" "github.com/shellhub-io/shellhub/pkg/uuid" uuidmocks "github.com/shellhub-io/shellhub/pkg/uuid/mocks" @@ -344,8 +346,11 @@ func TestGetNamespace(t *testing.T) { } func TestCreateNamespace(t *testing.T) { + envMock := new(envmock.Backend) storeMock := new(storemock.Store) clockMock := new(clockmock.Clock) + + envs.DefaultBackend = envMock clock.DefaultBackend = clockMock now := time.Now() @@ -530,7 +535,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -607,7 +611,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -631,7 +634,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -706,7 +708,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -730,7 +731,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -802,7 +802,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -826,7 +825,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -898,7 +896,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -922,7 +919,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, diff --git a/api/services/setup.go b/api/services/setup.go index db3b194628c..2e5b0553905 100644 --- a/api/services/setup.go +++ b/api/services/setup.go @@ -81,7 +81,6 @@ func (s *service) Setup(ctx context.Context, req requests.Setup) error { { ID: insertedID, Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: clock.Now(), }, }, diff --git a/api/services/setup_test.go b/api/services/setup_test.go index 27074e2c0d8..7eea9a43660 100644 --- a/api/services/setup_test.go +++ b/api/services/setup_test.go @@ -191,7 +191,6 @@ func TestSetup(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -282,7 +281,6 @@ func TestSetup(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -350,7 +348,6 @@ func TestSetup(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, diff --git a/api/store/membership-invitations.go b/api/store/membership-invitations.go new file mode 100644 index 00000000000..1f093773b9c --- /dev/null +++ b/api/store/membership-invitations.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + + "github.com/shellhub-io/shellhub/pkg/models" +) + +type MembershipInvitationsStore interface { + // MembershipInvitationCreate creates a new membership invitation. + MembershipInvitationCreate(ctx context.Context, invitation *models.MembershipInvitation) error + + // MembershipInvitationResolve retrieves the most recent membership invitation for the specified tenant and user. + // It returns the invitation or an error, if any. + MembershipInvitationResolve(ctx context.Context, tenantID, userID string) (*models.MembershipInvitation, error) + + // MembershipInvitationUpdate updates an existing membership invitation. + MembershipInvitationUpdate(ctx context.Context, invitation *models.MembershipInvitation) error +} diff --git a/api/store/mocks/store.go b/api/store/mocks/store.go index 70b61b9d7fd..41e3f81226c 100644 --- a/api/store/mocks/store.go +++ b/api/store/mocks/store.go @@ -552,6 +552,72 @@ func (_m *Store) GetStats(ctx context.Context, tenantID string) (*models.Stats, return r0, r1 } +// MembershipInvitationCreate provides a mock function with given fields: ctx, invitation +func (_m *Store) MembershipInvitationCreate(ctx context.Context, invitation *models.MembershipInvitation) error { + ret := _m.Called(ctx, invitation) + + if len(ret) == 0 { + panic("no return value specified for MembershipInvitationCreate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.MembershipInvitation) error); ok { + r0 = rf(ctx, invitation) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MembershipInvitationResolve provides a mock function with given fields: ctx, tenantID, userID +func (_m *Store) MembershipInvitationResolve(ctx context.Context, tenantID string, userID string) (*models.MembershipInvitation, error) { + ret := _m.Called(ctx, tenantID, userID) + + if len(ret) == 0 { + panic("no return value specified for MembershipInvitationResolve") + } + + var r0 *models.MembershipInvitation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*models.MembershipInvitation, error)); ok { + return rf(ctx, tenantID, userID) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *models.MembershipInvitation); ok { + r0 = rf(ctx, tenantID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.MembershipInvitation) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, tenantID, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MembershipInvitationUpdate provides a mock function with given fields: ctx, invitation +func (_m *Store) MembershipInvitationUpdate(ctx context.Context, invitation *models.MembershipInvitation) error { + ret := _m.Called(ctx, invitation) + + if len(ret) == 0 { + panic("no return value specified for MembershipInvitationUpdate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.MembershipInvitation) error); ok { + r0 = rf(ctx, invitation) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // NamespaceConflicts provides a mock function with given fields: ctx, target func (_m *Store) NamespaceConflicts(ctx context.Context, target *models.NamespaceConflicts) ([]string, bool, error) { ret := _m.Called(ctx, target) diff --git a/api/store/mongo/fixtures/membership_invitations.json b/api/store/mongo/fixtures/membership_invitations.json new file mode 100644 index 00000000000..ec192a80f27 --- /dev/null +++ b/api/store/mongo/fixtures/membership_invitations.json @@ -0,0 +1,28 @@ +{ + "membership_invitations": { + "507f1f77bcf86cd799439012": { + "tenant_id": "00000000-0000-4000-0000-000000000000", + "user_id": "6509e169ae6144b2f56bf288", + "invited_by": "507f1f77bcf86cd799439011", + "role": "observer", + "status": "pending", + "created_at": "2023-01-01T12:00:00.000Z", + "updated_at": "2023-01-02T12:00:00.000Z", + "status_updated_at": "2023-01-01T12:00:00.000Z", + "expires_at": "2023-01-08T12:00:00.000Z", + "invitations": 1 + }, + "507f1f77bcf86cd799439013": { + "tenant_id": "00000000-0000-4001-0000-000000000000", + "user_id": "907f1f77bcf86cd799439022", + "invited_by": "6509e169ae6144b2f56bf288", + "role": "administrator", + "status": "accepted", + "created_at": "2023-01-05T12:00:00.000Z", + "updated_at": "2023-01-06T12:00:00.000Z", + "status_updated_at": "2023-01-06T12:00:00.000Z", + "expires_at": "2023-01-12T12:00:00.000Z", + "invitations": 2 + } + } +} diff --git a/api/store/mongo/fixtures/namespaces.json b/api/store/mongo/fixtures/namespaces.json index 9c25f107889..81ac29e8156 100644 --- a/api/store/mongo/fixtures/namespaces.json +++ b/api/store/mongo/fixtures/namespaces.json @@ -7,14 +7,12 @@ { "id": "507f1f77bcf86cd799439011", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" }, { "id": "6509e169ae6144b2f56bf288", "added_at": "2023-01-01T12:00:00.000Z", - "role": "observer", - "status": "pending" + "role": "observer" } ], "name": "namespace-1", @@ -35,14 +33,12 @@ { "id": "6509e169ae6144b2f56bf288", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" }, { "id": "907f1f77bcf86cd799439022", "added_at": "2023-01-01T12:00:00.000Z", - "role": "operator", - "status": "accepted" + "role": "operator" } ], "name": "namespace-2", @@ -63,8 +59,7 @@ { "id": "657b0e3bff780d625f74e49a", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" } ], "name": "namespace-3", @@ -85,8 +80,7 @@ { "id": "6577267d8752d05270a4c07d", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" } ], "name": "namespace-4", diff --git a/api/store/mongo/member.go b/api/store/mongo/member.go index 614f1415ad8..efbc5ada6c8 100644 --- a/api/store/mongo/member.go +++ b/api/store/mongo/member.go @@ -22,11 +22,9 @@ func (s *Store) NamespaceCreateMembership(ctx context.Context, tenantID string, } memberBson := bson.M{ - "id": member.ID, - "added_at": member.AddedAt, - "expires_at": member.ExpiresAt, - "role": member.Role, - "status": member.Status, + "id": member.ID, + "added_at": member.AddedAt, + "role": member.Role, } res, err := s.db. @@ -51,11 +49,9 @@ func (s *Store) NamespaceUpdateMembership(ctx context.Context, tenantID string, filter := bson.M{"tenant_id": tenantID, "members": bson.M{"$elemMatch": bson.M{"id": member.ID}}} memberBson := bson.M{ - "members.$.id": member.ID, - "members.$.added_at": member.AddedAt, - "members.$.expires_at": member.ExpiresAt, - "members.$.role": member.Role, - "members.$.status": member.Status, + "members.$.id": member.ID, + "members.$.added_at": member.AddedAt, + "members.$.role": member.Role, } ns, err := s.db.Collection("namespaces").UpdateOne(ctx, filter, bson.M{"$set": memberBson}) diff --git a/api/store/mongo/member_test.go b/api/store/mongo/member_test.go index 3ed8462bbbc..997ef26080d 100644 --- a/api/store/mongo/member_test.go +++ b/api/store/mongo/member_test.go @@ -30,9 +30,8 @@ func TestNamespaceCreateMembership(t *testing.T) { description: "fails when tenant is not found", tenantID: "nonexistent", member: &models.Member{ - ID: "6509de884238881ac1b2b289", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "6509de884238881ac1b2b289", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: store.ErrNoDocuments}, @@ -41,9 +40,8 @@ func TestNamespaceCreateMembership(t *testing.T) { description: "fails when member has already been added", tenantID: "00000000-0000-4000-0000-000000000000", member: &models.Member{ - ID: "6509e169ae6144b2f56bf288", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: mongo.ErrNamespaceDuplicatedMember}, @@ -52,9 +50,8 @@ func TestNamespaceCreateMembership(t *testing.T) { description: "succeeds when tenant is found", tenantID: "00000000-0000-4000-0000-000000000000", member: &models.Member{ - ID: "6509de884238881ac1b2b289", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "6509de884238881ac1b2b289", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: nil}, @@ -97,9 +94,8 @@ func TestNamespaceUpdateMembership(t *testing.T) { description: "fails when user is not found", tenantID: "00000000-0000-4000-0000-000000000000", member: &models.Member{ - ID: "000000000000000000000000", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: mongo.ErrUserNotFound}, @@ -110,7 +106,6 @@ func TestNamespaceUpdateMembership(t *testing.T) { member: &models.Member{ ID: "6509e169ae6144b2f56bf288", Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, AddedAt: time.Now(), }, fixtures: []string{fixtureNamespaces}, @@ -138,7 +133,6 @@ func TestNamespaceUpdateMembership(t *testing.T) { require.Equal(t, 2, len(namespace.Members)) require.Equal(t, tc.member.ID, namespace.Members[1].ID) require.Equal(t, tc.member.Role, namespace.Members[1].Role) - require.Equal(t, tc.member.Status, namespace.Members[1].Status) }) } } diff --git a/api/store/mongo/membership-invitations.go b/api/store/mongo/membership-invitations.go new file mode 100644 index 00000000000..88ca20ff661 --- /dev/null +++ b/api/store/mongo/membership-invitations.go @@ -0,0 +1,85 @@ +package mongo + +import ( + "context" + + "github.com/shellhub-io/shellhub/api/store" + "github.com/shellhub-io/shellhub/pkg/clock" + "github.com/shellhub-io/shellhub/pkg/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func (s *Store) MembershipInvitationCreate(ctx context.Context, invitation *models.MembershipInvitation) error { + now := clock.Now() + invitation.CreatedAt = now + invitation.UpdatedAt = now + invitation.StatusUpdatedAt = now + + bsonBytes, err := bson.Marshal(invitation) + if err != nil { + return FromMongoError(err) + } + + doc := make(bson.M) + if err := bson.Unmarshal(bsonBytes, &doc); err != nil { + return FromMongoError(err) + } + + objID := primitive.NewObjectID() + doc["_id"] = objID + doc["user_id"], _ = primitive.ObjectIDFromHex(invitation.UserID) + doc["invited_by"], _ = primitive.ObjectIDFromHex(invitation.InvitedBy) + + if _, err := s.db.Collection("membership_invitations").InsertOne(ctx, doc); err != nil { + return FromMongoError(err) + } + + invitation.ID = objID.Hex() + + return nil +} + +func (s *Store) MembershipInvitationResolve(ctx context.Context, tenantID, userID string) (*models.MembershipInvitation, error) { + invitation := &models.MembershipInvitation{} + + userObjID, _ := primitive.ObjectIDFromHex(userID) + filter := bson.M{"tenant_id": tenantID, "user_id": userObjID} + opts := options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}}) + if err := s.db.Collection("membership_invitations").FindOne(ctx, filter, opts).Decode(invitation); err != nil { + return nil, FromMongoError(err) + } + + return invitation, nil +} + +func (s *Store) MembershipInvitationUpdate(ctx context.Context, invitation *models.MembershipInvitation) error { + invitation.UpdatedAt = clock.Now() + + bsonBytes, err := bson.Marshal(invitation) + if err != nil { + return FromMongoError(err) + } + + doc := make(bson.M) + if err := bson.Unmarshal(bsonBytes, &doc); err != nil { + return FromMongoError(err) + } + + delete(doc, "_id") + doc["user_id"], _ = primitive.ObjectIDFromHex(invitation.UserID) + doc["invited_by"], _ = primitive.ObjectIDFromHex(invitation.InvitedBy) + + objID, _ := primitive.ObjectIDFromHex(invitation.ID) + r, err := s.db.Collection("membership_invitations").UpdateOne(ctx, bson.M{"_id": objID}, bson.M{"$set": doc}) + if err != nil { + return FromMongoError(err) + } + + if r.MatchedCount == 0 { + return store.ErrNoDocuments + } + + return nil +} diff --git a/api/store/mongo/membership-invitations_test.go b/api/store/mongo/membership-invitations_test.go new file mode 100644 index 00000000000..4aeab360278 --- /dev/null +++ b/api/store/mongo/membership-invitations_test.go @@ -0,0 +1,270 @@ +package mongo_test + +import ( + "context" + "testing" + "time" + + "github.com/shellhub-io/shellhub/api/store" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" + "github.com/shellhub-io/shellhub/pkg/clock" + clockmock "github.com/shellhub-io/shellhub/pkg/clock/mocks" + "github.com/shellhub-io/shellhub/pkg/models" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestStore_MembershipInvitationCreate(t *testing.T) { + mockClock := new(clockmock.Clock) + clock.DefaultBackend = mockClock + + now := time.Now() + mockClock.On("Now").Return(now) + expiresAt := now.Add(7 * 24 * time.Hour) + + cases := []struct { + description string + invitation *models.MembershipInvitation + fixtures []string + expected map[string]any + }{ + { + description: "succeeds creating new invitation", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusPending, + ExpiresAt: &expiresAt, + Invitations: 1, + }, + fixtures: []string{}, + expected: map[string]any{ + "tenant_id": "00000000-0000-4000-0000-000000000000", + "role": "observer", + "status": "pending", + "created_at": primitive.NewDateTimeFromTime(now), + "updated_at": primitive.NewDateTimeFromTime(now), + "status_updated_at": primitive.NewDateTimeFromTime(now), + "invitations": int32(1), + }, + }, + { + description: "succeeds creating invitation with ID", + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439020", + TenantID: "00000000-0000-4001-0000-000000000000", + UserID: "907f1f77bcf86cd799439022", + InvitedBy: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusAccepted, + ExpiresAt: &expiresAt, + Invitations: 2, + }, + fixtures: []string{}, + expected: map[string]any{ + "tenant_id": "00000000-0000-4001-0000-000000000000", + "role": "administrator", + "status": "accepted", + "created_at": primitive.NewDateTimeFromTime(now), + "updated_at": primitive.NewDateTimeFromTime(now), + "status_updated_at": primitive.NewDateTimeFromTime(now), + "invitations": int32(2), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + ctx := context.Background() + + require.NoError(tt, srv.Apply(tc.fixtures...)) + tt.Cleanup(func() { + require.NoError(tt, srv.Reset()) + }) + + err := s.MembershipInvitationCreate(ctx, tc.invitation) + require.NoError(tt, err) + require.NotEmpty(tt, tc.invitation.ID) + + objID, _ := primitive.ObjectIDFromHex(tc.invitation.ID) + userObjID, _ := primitive.ObjectIDFromHex(tc.invitation.UserID) + invitedByObjID, _ := primitive.ObjectIDFromHex(tc.invitation.InvitedBy) + + tmpInvitation := make(map[string]any) + require.NoError(tt, db.Collection("membership_invitations").FindOne(ctx, bson.M{"_id": objID}).Decode(&tmpInvitation)) + + require.Equal(tt, objID, tmpInvitation["_id"]) + require.Equal(tt, userObjID, tmpInvitation["user_id"]) + require.Equal(tt, invitedByObjID, tmpInvitation["invited_by"]) + + for field, expectedValue := range tc.expected { + require.Equal(tt, expectedValue, tmpInvitation[field]) + } + }) + } +} + +func TestStore_MembershipInvitationResolve(t *testing.T) { + type Expected struct { + invitation *models.MembershipInvitation + err error + } + + cases := []struct { + description string + tenantID string + userID string + fixtures []string + expected Expected + }{ + { + description: "fails when invitation not found", + tenantID: "00000000-0000-4000-0000-000000000000", + userID: "000000000000000000000000", + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{invitation: nil, err: store.ErrNoDocuments}, + }, + { + description: "succeeds when invitation found", + tenantID: "00000000-0000-4000-0000-000000000000", + userID: "6509e169ae6144b2f56bf288", + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{ + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439012", + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusPending, + }, + err: nil, + }, + }, + { + description: "returns most recent when multiple invitations exist", + tenantID: "00000000-0000-4001-0000-000000000000", + userID: "907f1f77bcf86cd799439022", + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{ + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439013", + TenantID: "00000000-0000-4001-0000-000000000000", + UserID: "907f1f77bcf86cd799439022", + InvitedBy: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusAccepted, + }, + err: nil, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + ctx := context.Background() + + require.NoError(tt, srv.Apply(tc.fixtures...)) + tt.Cleanup(func() { + require.NoError(tt, srv.Reset()) + }) + + invitation, err := s.MembershipInvitationResolve(ctx, tc.tenantID, tc.userID) + + if tc.expected.err != nil { + require.Equal(tt, tc.expected.err, err) + require.Nil(tt, invitation) + } else { + require.NoError(tt, err) + require.NotNil(tt, invitation) + require.Equal(tt, tc.expected.invitation.ID, invitation.ID) + require.Equal(tt, tc.expected.invitation.TenantID, invitation.TenantID) + require.Equal(tt, tc.expected.invitation.UserID, invitation.UserID) + require.Equal(tt, tc.expected.invitation.InvitedBy, invitation.InvitedBy) + require.Equal(tt, tc.expected.invitation.Role, invitation.Role) + require.Equal(tt, tc.expected.invitation.Status, invitation.Status) + } + }) + } +} + +func TestStore_MembershipInvitationUpdate(t *testing.T) { + mockClock := new(clockmock.Clock) + clock.DefaultBackend = mockClock + + now := time.Now() + mockClock.On("Now").Return(now) + + type Expected struct { + err error + } + + cases := []struct { + description string + invitation *models.MembershipInvitation + fixtures []string + expected Expected + }{ + { + description: "fails when invitation not found", + invitation: &models.MembershipInvitation{ + ID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusExpired, + StatusUpdatedAt: now, + Invitations: 2, + }, + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{err: store.ErrNoDocuments}, + }, + { + description: "succeeds when invitation found", + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439012", + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusAccepted, + StatusUpdatedAt: now, + Invitations: 3, + }, + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{err: nil}, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + ctx := context.Background() + + require.NoError(tt, srv.Apply(tc.fixtures...)) + tt.Cleanup(func() { + require.NoError(tt, srv.Reset()) + }) + + err := s.MembershipInvitationUpdate(ctx, tc.invitation) + + if tc.expected.err != nil { + require.Equal(tt, tc.expected.err, err) + } else { + require.NoError(tt, err) + + objID, _ := primitive.ObjectIDFromHex(tc.invitation.ID) + updatedInvitation := &models.MembershipInvitation{} + require.NoError(tt, db.Collection("membership_invitations").FindOne(ctx, bson.M{"_id": objID}).Decode(updatedInvitation)) + + require.Equal(tt, tc.invitation.Role, updatedInvitation.Role) + require.Equal(tt, tc.invitation.Status, updatedInvitation.Status) + require.Equal(tt, tc.invitation.Invitations, updatedInvitation.Invitations) + require.Equal(tt, primitive.NewDateTimeFromTime(now), primitive.NewDateTimeFromTime(updatedInvitation.UpdatedAt)) + } + }) + } +} diff --git a/api/store/mongo/migrations/main.go b/api/store/mongo/migrations/main.go index 2f6c48218b9..9b3b673322b 100644 --- a/api/store/mongo/migrations/main.go +++ b/api/store/mongo/migrations/main.go @@ -126,6 +126,8 @@ func GenerateMigrations() []migrate.Migration { migration114, migration115, migration116, + migration117, + migration118, } } diff --git a/api/store/mongo/migrations/migration_117.go b/api/store/mongo/migrations/migration_117.go new file mode 100644 index 00000000000..d53c764a9d4 --- /dev/null +++ b/api/store/mongo/migrations/migration_117.go @@ -0,0 +1,129 @@ +package migrations + +import ( + "context" + + log "github.com/sirupsen/logrus" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +var migration117 = migrate.Migration{ + Version: 117, + Description: "Migrate member invitations from namespaces members array to membership_invitations collection", + Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 117, + "action": "Up", + }).Info("Applying migration up") + + session, err := db.Client().StartSession() + if err != nil { + return err + } + defer session.EndSession(ctx) + + _, err = session.WithTransaction(ctx, func(sCtx mongo.SessionContext) (any, error) { + cursor, err := db.Collection("namespaces").Find(sCtx, bson.M{}) + if err != nil { + log.WithError(err).Error("Failed to find namespaces") + + return nil, err + } + + defer cursor.Close(sCtx) + + invitations := make([]any, 0) + namespacesToUpdate := make([]bson.M, 0) + + for cursor.Next(sCtx) { + namespace := make(bson.M) + if err := cursor.Decode(&namespace); err != nil { + log.WithError(err).Error("Failed to decode namespace document") + + return nil, err + } + + if members, ok := namespace["members"].(bson.A); ok { + updatedMembers := make(bson.A, 0) + for _, m := range members { + if member, ok := m.(bson.M); ok { + if member["role"] != "owner" { + invitations = append( + invitations, + bson.M{ + "tenant_id": namespace["tenant_id"], + "user_id": member["id"], + "invited_by": namespace["owner"], + "role": member["role"], + "status": member["status"], + "created_at": member["added_at"], + "updated_at": member["added_at"], + "status_updated_at": member["added_at"], + "expires_at": member["expires_at"], + "invitations": 1, + }, + ) + } + + if member["status"] == "accepted" { + member := bson.M{"id": member["id"], "added_at": member["added_at"], "role": member["role"]} + updatedMembers = append(updatedMembers, member) + } + } + } + + namespace["members"] = updatedMembers + namespacesToUpdate = append(namespacesToUpdate, namespace) + } + } + + if err := cursor.Err(); err != nil { + log.WithError(err).Error("Cursor error while iterating namespaces") + + return nil, err + } + + if len(invitations) > 0 { + if _, err = db.Collection("membership_invitations").InsertMany(sCtx, invitations); err != nil { + log.WithError(err).Error("Failed to insert membership invitations") + + return nil, err + } + + log.WithField("count", len(invitations)).Info("Successfully migrated member invitations to membership_invitations collection") + } else { + log.Info("No member invitations found to migrate") + } + + for _, ns := range namespacesToUpdate { + nsID := ns["_id"] + if _, err = db.Collection("namespaces").ReplaceOne(sCtx, bson.M{"_id": nsID}, ns); err != nil { + log.WithError(err).Error("Failed to update namespace") + + return nil, err + } + } + + if len(namespacesToUpdate) > 0 { + log.WithField("count", len(namespacesToUpdate)).Info("Successfully updated namespaces with cleaned members") + } + + return nil, nil + }) + + return err + }), + + Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 117, + "action": "Down", + }).Warning("Migration down is not implemented - this migration cannot be reversed safely") + + return nil + }), +} diff --git a/api/store/mongo/migrations/migration_117_test.go b/api/store/mongo/migrations/migration_117_test.go new file mode 100644 index 00000000000..1e55aa16bf2 --- /dev/null +++ b/api/store/mongo/migrations/migration_117_test.go @@ -0,0 +1,169 @@ +package migrations + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestMigration117Up(t *testing.T) { + ctx := context.Background() + + cases := []struct { + description string + setup func() error + verify func(tt *testing.T) + }{ + { + description: "succeeds migrating namespace members to membership_invitations collection", + setup: func() error { + ownerID := primitive.NewObjectID() + memberID1 := primitive.NewObjectID() + memberID2 := primitive.NewObjectID() + + namespaces := []bson.M{ + { + "_id": primitive.NewObjectID(), + "name": "test-namespace-1", + "owner": ownerID, + "tenant_id": "tenant-1", + "members": bson.A{ + bson.M{ + "id": ownerID, + "added_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp()), + "role": "owner", + "status": "accepted", + "expires_at": nil, + }, + bson.M{ + "id": memberID1, + "added_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp()), + "role": "observer", + "status": "pending", + "expires_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp().Add(7 * 24 * 60 * 60 * 1000)), + }, + bson.M{ + "id": memberID2, + "added_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp()), + "role": "administrator", + "status": "accepted", + "expires_at": nil, + }, + }, + }, + } + + _, err := c.Database("test").Collection("namespaces").InsertMany(ctx, []any{namespaces[0]}) + + return err + }, + verify: func(tt *testing.T) { + cursor, err := c.Database("test").Collection("membership_invitations").Find(ctx, bson.M{}) + require.NoError(tt, err) + + invitations := make([]bson.M, 0) + require.NoError(tt, cursor.All(ctx, &invitations)) + require.Equal(tt, 2, len(invitations)) + + ownerFound := false + for _, invitation := range invitations { + require.NotNil(tt, invitation["_id"]) + require.Equal(tt, "tenant-1", invitation["tenant_id"]) + require.NotNil(tt, invitation["user_id"]) + require.NotNil(tt, invitation["invited_by"]) + require.NotNil(tt, invitation["role"]) + require.NotNil(tt, invitation["status"]) + require.NotNil(tt, invitation["created_at"]) + require.NotNil(tt, invitation["updated_at"]) + require.NotNil(tt, invitation["status_updated_at"]) + require.Equal(tt, int32(1), invitation["invitations"]) + + require.NotEqual(tt, "owner", invitation["role"]) + if invitation["role"] == "owner" { + ownerFound = true + } + } + require.False(tt, ownerFound, "Owner should not have an invitation created") + + namespaceCursor, err := c.Database("test").Collection("namespaces").Find(ctx, bson.M{"tenant_id": "tenant-1"}) + require.NoError(tt, err) + + namespaces := make([]bson.M, 0) + require.NoError(tt, namespaceCursor.All(ctx, &namespaces)) + require.Equal(tt, 1, len(namespaces)) + + namespace := namespaces[0] + members, ok := namespace["members"].(bson.A) + require.True(tt, ok) + require.Equal(tt, 2, len(members)) + + for _, m := range members { + member, ok := m.(bson.M) + require.True(tt, ok) + require.NotNil(tt, member["id"]) + require.NotNil(tt, member["added_at"]) + require.NotNil(tt, member["role"]) + require.Nil(tt, member["status"]) + require.Nil(tt, member["expires_at"]) + } + }, + }, + { + description: "handles namespace with no members gracefully", + setup: func() error { + namespaces := []bson.M{ + { + "_id": primitive.NewObjectID(), + "name": "empty-namespace", + "owner": primitive.NewObjectID(), + "tenant_id": "tenant-empty", + "members": bson.A{}, + }, + } + + _, err := c.Database("test").Collection("namespaces").InsertMany(ctx, []any{namespaces[0]}) + + return err + }, + verify: func(tt *testing.T) { + count, err := c.Database("test").Collection("membership_invitations").CountDocuments(ctx, bson.M{}) + require.NoError(tt, err) + require.Equal(tt, int64(0), count) + + namespaceCount, err := c.Database("test").Collection("namespaces").CountDocuments(ctx, bson.M{"tenant_id": "tenant-empty"}) + require.NoError(tt, err) + require.Equal(tt, int64(1), namespaceCount) + }, + }, + { + description: "handles empty namespaces collection gracefully", + setup: func() error { + return nil + }, + verify: func(tt *testing.T) { + count, err := c.Database("test").Collection("membership_invitations").CountDocuments(ctx, bson.M{}) + require.NoError(tt, err) + require.Equal(tt, int64(0), count) + + namespaceCount, err := c.Database("test").Collection("namespaces").CountDocuments(ctx, bson.M{}) + require.NoError(tt, err) + require.Equal(tt, int64(0), namespaceCount) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + tt.Cleanup(func() { require.NoError(tt, srv.Reset()) }) + + require.NoError(tt, tc.setup()) + migrates := migrate.NewMigrate(c.Database("test"), GenerateMigrations()[116]) + require.NoError(tt, migrates.Up(ctx, migrate.AllAvailable)) + tc.verify(tt) + }) + } +} diff --git a/api/store/mongo/migrations/migration_118.go b/api/store/mongo/migrations/migration_118.go new file mode 100644 index 00000000000..077a9bfa564 --- /dev/null +++ b/api/store/mongo/migrations/migration_118.go @@ -0,0 +1,95 @@ +package migrations + +import ( + "context" + + log "github.com/sirupsen/logrus" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var migration118 = migrate.Migration{ + Version: 118, + Description: "Create indexes on membership_invitations collection", + Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 118, + "action": "Up", + }).Info("Applying migration up") + + indexes := []struct { + name string + model mongo.IndexModel + }{ + { + name: "tenant_user_status_pending_unique", + model: mongo.IndexModel{ + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "user_id", Value: 1}, + {Key: "status", Value: 1}, + }, + Options: options.Index(). + SetName("tenant_user_status_pending_unique"). + SetUnique(true). + SetPartialFilterExpression(bson.M{"status": "pending"}), + }, + }, + { + name: "tenant_user_created_at", + model: mongo.IndexModel{ + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "user_id", Value: 1}, + }, + Options: options.Index().SetName("tenant_user_created_at"), + }, + }, + { + name: "user_status", + model: mongo.IndexModel{ + Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "status", Value: 1}, + }, + Options: options.Index().SetName("user_status"), + }, + }, + } + + for _, ix := range indexes { + if _, err := db.Collection("membership_invitations").Indexes().CreateOne(ctx, ix.model); err != nil { + log.WithError(err).WithField("index", ix.name).Error("Failed to create index") + + return err + } + } + + log.Info("Successfully created indexes on membership_invitations collection") + + return nil + }), + Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 118, + "action": "Down", + }).Info("Applying migration down") + + indexes := []string{"tenant_user_status_pending_unique", "tenant_user_created_at", "user_status"} + for _, ix := range indexes { + if _, err := db.Collection("membership_invitations").Indexes().DropOne(ctx, ix); err != nil { + log.WithError(err).WithField("index", ix).Error("Failed to drop index") + + return err + } + } + + log.Info("Successfully dropped indexes from membership_invitations collection") + + return nil + }), +} diff --git a/api/store/mongo/migrations/migration_72.go b/api/store/mongo/migrations/migration_72.go index e99e3f6a7a7..d888c752c07 100644 --- a/api/store/mongo/migrations/migration_72.go +++ b/api/store/mongo/migrations/migration_72.go @@ -2,7 +2,9 @@ package migrations import ( "context" + "time" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" "github.com/shellhub-io/shellhub/pkg/models" log "github.com/sirupsen/logrus" migrate "github.com/xakep666/mongo-migrate" @@ -10,6 +12,21 @@ import ( "go.mongodb.org/mongo-driver/mongo" ) +// Member struct as it was when migration 72 was created (with Status field) +type memberForMigration72 struct { + ID string `json:"id,omitempty" bson:"id,omitempty"` + AddedAt time.Time `json:"added_at" bson:"added_at"` + Email string `json:"email" bson:"email,omitempty" validate:"email"` + Role authorizer.Role `json:"role" bson:"role" validate:"required,oneof=administrator operator observer"` + Status string `json:"status" bson:"status"` +} + +// Namespace struct for migration 72 with the old Member type +type namespaceForMigration72 struct { + models.Namespace `bson:",inline"` + Members []memberForMigration72 `json:"members" bson:"members"` +} + var migration72 = migrate.Migration{ Version: 72, Description: "Adding the 'members.$.status' attribute to the namespace if it does not already exist.", @@ -39,7 +56,7 @@ var migration72 = migrate.Migration{ updateModels := make([]mongo.WriteModel, 0) for cursor.Next(ctx) { - namespace := new(models.Namespace) + namespace := new(namespaceForMigration72) if err := cursor.Decode(namespace); err != nil { return err } @@ -49,7 +66,7 @@ var migration72 = migrate.Migration{ updateModel := mongo. NewUpdateOneModel(). SetFilter(bson.M{"tenant_id": namespace.TenantID, "members": bson.M{"$elemMatch": bson.M{"id": m.ID}}}). - SetUpdate(bson.M{"$set": bson.M{"members.$.status": models.DeviceStatusAccepted}}) + SetUpdate(bson.M{"$set": bson.M{"members.$.status": "accepted"}}) updateModels = append(updateModels, updateModel) } @@ -90,7 +107,7 @@ var migration72 = migrate.Migration{ updateModels := make([]mongo.WriteModel, 0) for cursor.Next(ctx) { - namespace := new(models.Namespace) + namespace := new(namespaceForMigration72) if err := cursor.Decode(namespace); err != nil { return err } diff --git a/api/store/mongo/namespace.go b/api/store/mongo/namespace.go index d15bd741bf7..8281e6aca81 100644 --- a/api/store/mongo/namespace.go +++ b/api/store/mongo/namespace.go @@ -30,9 +30,6 @@ func (s *Store) NamespaceList(ctx context.Context, opts ...store.QueryOption) ([ "members": bson.M{ "$elemMatch": bson.M{ "id": user.ID, - "status": bson.M{ - "$ne": models.MemberStatusPending, - }, }, }, }, diff --git a/api/store/mongo/namespace_test.go b/api/store/mongo/namespace_test.go index fa91041bbf3..6a5266385d2 100644 --- a/api/store/mongo/namespace_test.go +++ b/api/store/mongo/namespace_test.go @@ -53,13 +53,11 @@ func TestNamespaceList(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, }, }, MaxDevices: -1, @@ -79,13 +77,11 @@ func TestNamespaceList(t *testing.T) { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, { ID: "907f1f77bcf86cd799439022", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOperator, - Status: models.MemberStatusAccepted, }, }, MaxDevices: 10, @@ -105,7 +101,6 @@ func TestNamespaceList(t *testing.T) { ID: "657b0e3bff780d625f74e49a", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, }, MaxDevices: 3, @@ -125,7 +120,6 @@ func TestNamespaceList(t *testing.T) { ID: "6577267d8752d05270a4c07d", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, }, MaxDevices: -1, @@ -206,14 +200,12 @@ func TestNamespaceResolve(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, Email: "john.doe@test.com", }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, Email: "maria.garcia@test.com", }, }, @@ -253,14 +245,12 @@ func TestNamespaceResolve(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, Email: "john.doe@test.com", }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, Email: "maria.garcia@test.com", }, }, @@ -327,13 +317,11 @@ func TestNamespaceGetPreferred(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, }, }, MaxDevices: -1, diff --git a/api/store/mongo/store_test.go b/api/store/mongo/store_test.go index d4a9cee074a..4920f6a8e74 100644 --- a/api/store/mongo/store_test.go +++ b/api/store/mongo/store_test.go @@ -24,18 +24,19 @@ var ( ) const ( - fixtureAPIKeys = "api-key" // Check "store.mongo.fixtures.api-keys" for fixture info - fixtureDevices = "devices" // Check "store.mongo.fixtures.devices" for fixture info - fixtureSessions = "sessions" // Check "store.mongo.fixtures.sessions" for fixture info - fixtureActiveSessions = "active_sessions" // Check "store.mongo.fixtures.active_sessions" for fixture info - fixtureFirewallRules = "firewall_rules" // Check "store.mongo.fixtures.firewall_rules" for fixture info - fixturePublicKeys = "public_keys" // Check "store.mongo.fixtures.public_keys" for fixture info - fixturePrivateKeys = "private_keys" // Check "store.mongo.fixtures.private_keys" for fixture info - fixtureUsers = "users" // Check "store.mongo.fixtures.users" for fixture iefo - fixtureNamespaces = "namespaces" // Check "store.mongo.fixtures.namespaces" for fixture info - fixtureRecoveryTokens = "recovery_tokens" // Check "store.mongo.fixtures.recovery_tokens" for fixture info - fixtureTags = "tags" // Check "store.mongo.fixtures.tags" for fixture info - fixtureUserInvitations = "user_invitations" // Check "store.mongo.fixtures.user_invitations" for fixture info + fixtureAPIKeys = "api-key" // Check "store.mongo.fixtures.api-keys" for fixture info + fixtureDevices = "devices" // Check "store.mongo.fixtures.devices" for fixture info + fixtureSessions = "sessions" // Check "store.mongo.fixtures.sessions" for fixture info + fixtureActiveSessions = "active_sessions" // Check "store.mongo.fixtures.active_sessions" for fixture info + fixtureFirewallRules = "firewall_rules" // Check "store.mongo.fixtures.firewall_rules" for fixture info + fixturePublicKeys = "public_keys" // Check "store.mongo.fixtures.public_keys" for fixture info + fixturePrivateKeys = "private_keys" // Check "store.mongo.fixtures.private_keys" for fixture info + fixtureUsers = "users" // Check "store.mongo.fixtures.users" for fixture iefo + fixtureNamespaces = "namespaces" // Check "store.mongo.fixtures.namespaces" for fixture info + fixtureRecoveryTokens = "recovery_tokens" // Check "store.mongo.fixtures.recovery_tokens" for fixture info + fixtureTags = "tags" // Check "store.mongo.fixtures.tags" for fixture info + fixtureUserInvitations = "user_invitations" // Check "store.mongo.fixtures.user_invitations" for fixture info + fixtureMembershipInvitations = "membership_invitations" // Check "store.mongo.fixtures.membership_invitations" for fixture info ) func TestMain(m *testing.M) { @@ -53,6 +54,13 @@ func TestMain(m *testing.M) { mongotest.SimpleConvertObjID("user_invitations", "_id"), mongotest.SimpleConvertTime("user_invitations", "created_at"), mongotest.SimpleConvertTime("user_invitations", "updated_at"), + mongotest.SimpleConvertObjID("membership_invitations", "_id"), + mongotest.SimpleConvertObjID("membership_invitations", "user_id"), + mongotest.SimpleConvertObjID("membership_invitations", "invited_by"), + mongotest.SimpleConvertTime("membership_invitations", "created_at"), + mongotest.SimpleConvertTime("membership_invitations", "updated_at"), + mongotest.SimpleConvertTime("membership_invitations", "status_updated_at"), + mongotest.SimpleConvertTime("membership_invitations", "expires_at"), mongotest.SimpleConvertObjID("public_keys", "_id"), mongotest.SimpleConvertBytes("public_keys", "data"), mongotest.SimpleConvertTime("public_keys", "created_at"), diff --git a/api/store/store.go b/api/store/store.go index 8ed8f43d276..f05b8a75a0e 100644 --- a/api/store/store.go +++ b/api/store/store.go @@ -9,6 +9,7 @@ type Store interface { UserInvitationsStore NamespaceStore MemberStore + MembershipInvitationsStore PublicKeyStore PrivateKeyStore StatsStore diff --git a/cli/services/namespaces.go b/cli/services/namespaces.go index 9d61d10625f..0cb52871a45 100644 --- a/cli/services/namespaces.go +++ b/cli/services/namespaces.go @@ -44,7 +44,6 @@ func (s *service) NamespaceCreate(ctx context.Context, input *inputs.NamespaceCr ID: user.ID, Role: authorizer.RoleOwner, AddedAt: clock.Now(), - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -88,7 +87,6 @@ func (s *service) NamespaceAddMember(ctx context.Context, input *inputs.MemberAd ID: user.ID, Role: input.Role, AddedAt: clock.Now(), - Status: models.MemberStatusAccepted, }); err != nil { return nil, ErrFailedNamespaceAddMember } diff --git a/cli/services/namespaces_test.go b/cli/services/namespaces_test.go index e990076d012..e83dbaf72c0 100644 --- a/cli/services/namespaces_test.go +++ b/cli/services/namespaces_test.go @@ -108,7 +108,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -152,7 +151,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -174,7 +172,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -215,7 +212,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -237,7 +233,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -278,7 +273,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -300,7 +294,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -341,7 +334,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -363,7 +355,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -404,7 +395,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -426,7 +416,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -533,7 +522,6 @@ func TestNamespaceAddMember(t *testing.T) { ID: "507f191e810c19729de860ea", Role: authorizer.RoleObserver, AddedAt: now, - Status: models.MemberStatusAccepted, }).Return(nil).Once() }, expected: Expected{&models.Namespace{ diff --git a/pkg/models/member.go b/pkg/models/member.go index 758f8e991b2..77bea7908b0 100644 --- a/pkg/models/member.go +++ b/pkg/models/member.go @@ -6,22 +6,9 @@ import ( "github.com/shellhub-io/shellhub/pkg/api/authorizer" ) -type MemberStatus string - -const ( - MemberStatusPending MemberStatus = "pending" - MemberStatusAccepted MemberStatus = "accepted" -) - type Member struct { - ID string `json:"id,omitempty" bson:"id,omitempty"` - AddedAt time.Time `json:"added_at" bson:"added_at"` - - // ExpiresAt specifies the expiration date of the invite. This attribute is only applicable in *Cloud* instances, - // and it is ignored for members whose status is not 'pending'. - ExpiresAt time.Time `json:"expires_at" bson:"expires_at"` - - Email string `json:"email" bson:"email,omitempty" validate:"email"` - Role authorizer.Role `json:"role" bson:"role" validate:"required,oneof=administrator operator observer"` - Status MemberStatus `json:"status" bson:"status"` + ID string `json:"id,omitempty" bson:"id,omitempty"` + AddedAt time.Time `json:"added_at" bson:"added_at"` + Email string `json:"email" bson:"email,omitempty" validate:"email"` + Role authorizer.Role `json:"role" bson:"role" validate:"required,oneof=administrator operator observer"` } diff --git a/pkg/models/membership-invitation.go b/pkg/models/membership-invitation.go new file mode 100644 index 00000000000..0bffac497d2 --- /dev/null +++ b/pkg/models/membership-invitation.go @@ -0,0 +1,39 @@ +package models + +import ( + "time" + + "github.com/shellhub-io/shellhub/pkg/api/authorizer" + "github.com/shellhub-io/shellhub/pkg/clock" +) + +type MembershipInvitationStatus string + +const ( + MembershipInvitationStatusPending MembershipInvitationStatus = "pending" + MembershipInvitationStatusAccepted MembershipInvitationStatus = "accepted" + MembershipInvitationStatusRejected MembershipInvitationStatus = "rejected" + MembershipInvitationStatusCancelled MembershipInvitationStatus = "cancelled" +) + +type MembershipInvitation struct { + ID string `json:"-" bson:"_id"` + TenantID string `json:"tenant_id" bson:"tenant_id"` + UserID string `json:"user_id" bson:"user_id"` + InvitedBy string `json:"invited_by" bson:"invited_by"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` + ExpiresAt *time.Time `json:"expires_at" bson:"expires_at"` + Status MembershipInvitationStatus `json:"status" bson:"status"` + StatusUpdatedAt time.Time `json:"status_updated_at" bson:"status_updated_at"` + Role authorizer.Role `json:"role" bson:"role"` + Invitations int `json:"-" bson:"invitations"` +} + +func (m MembershipInvitation) IsExpired() bool { + return m.ExpiresAt != nil && m.ExpiresAt.Before(clock.Now()) +} + +func (m MembershipInvitation) IsPending() bool { + return m.Status == MembershipInvitationStatusPending +}