Files
netbird/management/server/http/handlers/users/invites_handler_test.go
2026-01-27 09:42:20 +01:00

643 lines
20 KiB
Go

package users
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/auth"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/status"
)
const (
testAccountID = "test-account-id"
testUserID = "test-user-id"
testInviteID = "test-invite-id"
testInviteToken = "nbi_testtoken123456789012345678"
testEmail = "invite@example.com"
testName = "Test User"
)
func setupInvitesTestHandler(am *mock_server.MockAccountManager) *invitesHandler {
return &invitesHandler{
accountManager: am,
}
}
func TestListInvites(t *testing.T) {
now := time.Now().UTC()
testInvites := []*types.UserInvite{
{
UserInfo: &types.UserInfo{
ID: "invite-1",
Email: "user1@example.com",
Name: "User One",
Role: "user",
AutoGroups: []string{"group-1"},
},
InviteExpiresAt: now.Add(24 * time.Hour),
InviteCreatedAt: now,
},
{
UserInfo: &types.UserInfo{
ID: "invite-2",
Email: "user2@example.com",
Name: "User Two",
Role: "admin",
AutoGroups: nil,
},
InviteExpiresAt: now.Add(-1 * time.Hour), // Expired
InviteCreatedAt: now.Add(-48 * time.Hour),
},
}
tt := []struct {
name string
expectedStatus int
mockFunc func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error)
expectedCount int
}{
{
name: "successful list",
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
return testInvites, nil
},
expectedCount: 2,
},
{
name: "empty list",
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
return []*types.UserInvite{}, nil
},
expectedCount: 0,
},
{
name: "permission denied",
expectedStatus: http.StatusForbidden,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
return nil, status.NewPermissionDeniedError()
},
expectedCount: 0,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
am := &mock_server.MockAccountManager{
ListUserInvitesFunc: tc.mockFunc,
}
handler := setupInvitesTestHandler(am)
req := httptest.NewRequest(http.MethodGet, "/api/users/invites", nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
rr := httptest.NewRecorder()
handler.listInvites(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
if tc.expectedStatus == http.StatusOK {
var resp []api.UserInvite
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Len(t, resp, tc.expectedCount)
}
})
}
}
func TestCreateInvite(t *testing.T) {
now := time.Now().UTC()
expiresAt := now.Add(72 * time.Hour)
tt := []struct {
name string
requestBody string
expectedStatus int
mockFunc func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error)
}{
{
name: "successful create",
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":["group-1"]}`,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
return &types.UserInvite{
UserInfo: &types.UserInfo{
ID: testInviteID,
Email: invite.Email,
Name: invite.Name,
Role: invite.Role,
AutoGroups: invite.AutoGroups,
Status: string(types.UserStatusInvited),
},
InviteToken: testInviteToken,
InviteExpiresAt: expiresAt,
}, nil
},
},
{
name: "successful create with custom expiration",
requestBody: `{"email":"test@example.com","name":"Test User","role":"admin","auto_groups":[],"expires_in":3600}`,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
assert.Equal(t, 3600, expiresIn)
return &types.UserInvite{
UserInfo: &types.UserInfo{
ID: testInviteID,
Email: invite.Email,
Name: invite.Name,
Role: invite.Role,
AutoGroups: []string{},
Status: string(types.UserStatusInvited),
},
InviteToken: testInviteToken,
InviteExpiresAt: expiresAt,
}, nil
},
},
{
name: "user already exists",
requestBody: `{"email":"existing@example.com","name":"Existing User","role":"user","auto_groups":[]}`,
expectedStatus: http.StatusConflict,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists")
},
},
{
name: "invite already exists",
requestBody: `{"email":"invited@example.com","name":"Invited User","role":"user","auto_groups":[]}`,
expectedStatus: http.StatusConflict,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email")
},
},
{
name: "permission denied",
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`,
expectedStatus: http.StatusForbidden,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
return nil, status.NewPermissionDeniedError()
},
},
{
name: "embedded IDP not enabled",
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`,
expectedStatus: http.StatusPreconditionFailed,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
},
},
{
name: "invalid JSON",
requestBody: `{invalid json}`,
expectedStatus: http.StatusBadRequest,
mockFunc: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
am := &mock_server.MockAccountManager{
CreateUserInviteFunc: tc.mockFunc,
}
handler := setupInvitesTestHandler(am)
req := httptest.NewRequest(http.MethodPost, "/api/users/invites", bytes.NewBufferString(tc.requestBody))
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
rr := httptest.NewRecorder()
handler.createInvite(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
if tc.expectedStatus == http.StatusOK {
var resp api.UserInvite
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, testInviteID, resp.Id)
assert.NotNil(t, resp.InviteToken)
assert.NotEmpty(t, *resp.InviteToken)
}
})
}
}
func TestGetInviteInfo(t *testing.T) {
now := time.Now().UTC()
tt := []struct {
name string
token string
expectedStatus int
mockFunc func(ctx context.Context, token string) (*types.UserInviteInfo, error)
}{
{
name: "successful get valid invite",
token: testInviteToken,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
return &types.UserInviteInfo{
Email: testEmail,
Name: testName,
ExpiresAt: now.Add(24 * time.Hour),
Valid: true,
InvitedBy: "Admin User",
}, nil
},
},
{
name: "successful get expired invite",
token: testInviteToken,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
return &types.UserInviteInfo{
Email: testEmail,
Name: testName,
ExpiresAt: now.Add(-24 * time.Hour),
Valid: false,
InvitedBy: "Admin User",
}, nil
},
},
{
name: "invite not found",
token: "nbi_invalidtoken1234567890123456",
expectedStatus: http.StatusNotFound,
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
return nil, status.Errorf(status.NotFound, "invite not found")
},
},
{
name: "invalid token format",
token: "invalid",
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
return nil, status.Errorf(status.InvalidArgument, "invalid invite token")
},
},
{
name: "missing token",
token: "",
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
am := &mock_server.MockAccountManager{
GetUserInviteInfoFunc: tc.mockFunc,
}
handler := setupInvitesTestHandler(am)
req := httptest.NewRequest(http.MethodGet, "/api/users/invites/"+tc.token, nil)
if tc.token != "" {
req = mux.SetURLVars(req, map[string]string{"token": tc.token})
}
rr := httptest.NewRecorder()
handler.getInviteInfo(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
if tc.expectedStatus == http.StatusOK {
var resp api.UserInviteInfo
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, testEmail, resp.Email)
assert.Equal(t, testName, resp.Name)
}
})
}
}
func TestAcceptInvite(t *testing.T) {
tt := []struct {
name string
token string
requestBody string
expectedStatus int
mockFunc func(ctx context.Context, token, password string) error
}{
{
name: "successful accept",
token: testInviteToken,
requestBody: `{"password":"SecurePass123!"}`,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, token, password string) error {
return nil
},
},
{
name: "invite not found",
token: "nbi_invalidtoken1234567890123456",
requestBody: `{"password":"SecurePass123!"}`,
expectedStatus: http.StatusNotFound,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.NotFound, "invite not found")
},
},
{
name: "invite expired",
token: testInviteToken,
requestBody: `{"password":"SecurePass123!"}`,
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.InvalidArgument, "invite has expired")
},
},
{
name: "embedded IDP not enabled",
token: testInviteToken,
requestBody: `{"password":"SecurePass123!"}`,
expectedStatus: http.StatusPreconditionFailed,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
},
},
{
name: "missing token",
token: "",
requestBody: `{"password":"SecurePass123!"}`,
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: nil,
},
{
name: "invalid JSON",
token: testInviteToken,
requestBody: `{invalid}`,
expectedStatus: http.StatusBadRequest,
mockFunc: nil,
},
{
name: "password too short",
token: testInviteToken,
requestBody: `{"password":"Short1!"}`,
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.InvalidArgument, "password must be at least 8 characters long")
},
},
{
name: "password missing digit",
token: testInviteToken,
requestBody: `{"password":"NoDigitPass!"}`,
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.InvalidArgument, "password must contain at least one digit")
},
},
{
name: "password missing uppercase",
token: testInviteToken,
requestBody: `{"password":"nouppercase1!"}`,
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.InvalidArgument, "password must contain at least one uppercase letter")
},
},
{
name: "password missing special character",
token: testInviteToken,
requestBody: `{"password":"NoSpecial123"}`,
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.InvalidArgument, "password must contain at least one special character")
},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
am := &mock_server.MockAccountManager{
AcceptUserInviteFunc: tc.mockFunc,
}
handler := setupInvitesTestHandler(am)
req := httptest.NewRequest(http.MethodPost, "/api/users/invites/"+tc.token+"/accept", bytes.NewBufferString(tc.requestBody))
if tc.token != "" {
req = mux.SetURLVars(req, map[string]string{"token": tc.token})
}
rr := httptest.NewRecorder()
handler.acceptInvite(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
if tc.expectedStatus == http.StatusOK {
var resp api.UserInviteAcceptResponse
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.True(t, resp.Success)
}
})
}
}
func TestRegenerateInvite(t *testing.T) {
now := time.Now().UTC()
expiresAt := now.Add(72 * time.Hour)
tt := []struct {
name string
inviteID string
requestBody string
expectedStatus int
mockFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error)
}{
{
name: "successful regenerate with empty body",
inviteID: testInviteID,
requestBody: "",
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
assert.Equal(t, 0, expiresIn)
return &types.UserInvite{
UserInfo: &types.UserInfo{
ID: inviteID,
Email: testEmail,
},
InviteToken: "nbi_newtoken12345678901234567890",
InviteExpiresAt: expiresAt,
}, nil
},
},
{
name: "successful regenerate with custom expiration",
inviteID: testInviteID,
requestBody: `{"expires_in":7200}`,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
assert.Equal(t, 7200, expiresIn)
return &types.UserInvite{
UserInfo: &types.UserInfo{
ID: inviteID,
Email: testEmail,
},
InviteToken: "nbi_newtoken12345678901234567890",
InviteExpiresAt: expiresAt,
}, nil
},
},
{
name: "invite not found",
inviteID: "non-existent-invite",
requestBody: "",
expectedStatus: http.StatusNotFound,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
return nil, status.Errorf(status.NotFound, "invite not found")
},
},
{
name: "permission denied",
inviteID: testInviteID,
requestBody: "",
expectedStatus: http.StatusForbidden,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
return nil, status.NewPermissionDeniedError()
},
},
{
name: "missing invite ID",
inviteID: "",
requestBody: "",
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: nil,
},
{
name: "invalid JSON should return error",
inviteID: testInviteID,
requestBody: `{invalid json}`,
expectedStatus: http.StatusBadRequest,
mockFunc: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
am := &mock_server.MockAccountManager{
RegenerateUserInviteFunc: tc.mockFunc,
}
handler := setupInvitesTestHandler(am)
var body io.Reader
if tc.requestBody != "" {
body = bytes.NewBufferString(tc.requestBody)
}
req := httptest.NewRequest(http.MethodPost, "/api/users/invites/"+tc.inviteID+"/regenerate", body)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
if tc.inviteID != "" {
req = mux.SetURLVars(req, map[string]string{"inviteId": tc.inviteID})
}
rr := httptest.NewRecorder()
handler.regenerateInvite(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
if tc.expectedStatus == http.StatusOK {
var resp api.UserInviteRegenerateResponse
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.InviteToken)
}
})
}
}
func TestDeleteInvite(t *testing.T) {
tt := []struct {
name string
inviteID string
expectedStatus int
mockFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string) error
}{
{
name: "successful delete",
inviteID: testInviteID,
expectedStatus: http.StatusOK,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
return nil
},
},
{
name: "invite not found",
inviteID: "non-existent-invite",
expectedStatus: http.StatusNotFound,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
return status.Errorf(status.NotFound, "invite not found")
},
},
{
name: "permission denied",
inviteID: testInviteID,
expectedStatus: http.StatusForbidden,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
return status.NewPermissionDeniedError()
},
},
{
name: "embedded IDP not enabled",
inviteID: testInviteID,
expectedStatus: http.StatusPreconditionFailed,
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
},
},
{
name: "missing invite ID",
inviteID: "",
expectedStatus: http.StatusUnprocessableEntity,
mockFunc: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
am := &mock_server.MockAccountManager{
DeleteUserInviteFunc: tc.mockFunc,
}
handler := setupInvitesTestHandler(am)
req := httptest.NewRequest(http.MethodDelete, "/api/users/invites/"+tc.inviteID, nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
if tc.inviteID != "" {
req = mux.SetURLVars(req, map[string]string{"inviteId": tc.inviteID})
}
rr := httptest.NewRecorder()
handler.deleteInvite(rr, req)
assert.Equal(t, tc.expectedStatus, rr.Code)
})
}
}