mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-19 16:56:39 +00:00
[management, infrastructure, idp] Simplified IdP Management - Embedded IdP (#5008)
Embed Dex as a built-in IdP to simplify self-hosting setup. Adds an embedded OIDC Identity Provider (Dex) with local user management and optional external IdP connectors (Google/GitHub/OIDC/SAML), plus device-auth flow for CLI login. Introduces instance onboarding/setup endpoints (including owner creation), field-level encryption for sensitive user data, a streamlined self-hosting provisioning script, and expanded APIs + test coverage for IdP management. more at https://github.com/netbirdio/netbird/pull/5008#issuecomment-3718987393
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
nbinstance "github.com/netbirdio/netbird/management/server/instance"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// mockInstanceManager implements instance.Manager for testing
|
||||
type mockInstanceManager struct {
|
||||
isSetupRequired bool
|
||||
isSetupRequiredFn func(ctx context.Context) (bool, error)
|
||||
createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||
}
|
||||
|
||||
func (m *mockInstanceManager) IsSetupRequired(ctx context.Context) (bool, error) {
|
||||
if m.isSetupRequiredFn != nil {
|
||||
return m.isSetupRequiredFn(ctx)
|
||||
}
|
||||
return m.isSetupRequired, nil
|
||||
}
|
||||
|
||||
func (m *mockInstanceManager) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||
if m.createOwnerUserFn != nil {
|
||||
return m.createOwnerUserFn(ctx, email, password, name)
|
||||
}
|
||||
|
||||
// Default mock includes validation like the real manager
|
||||
if !m.isSetupRequired {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "setup already completed")
|
||||
}
|
||||
if email == "" {
|
||||
return nil, status.Errorf(status.InvalidArgument, "email is required")
|
||||
}
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invalid email format")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, status.Errorf(status.InvalidArgument, "name is required")
|
||||
}
|
||||
if password == "" {
|
||||
return nil, status.Errorf(status.InvalidArgument, "password is required")
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return nil, status.Errorf(status.InvalidArgument, "password must be at least 8 characters")
|
||||
}
|
||||
|
||||
return &idp.UserData{
|
||||
ID: "test-user-id",
|
||||
Email: email,
|
||||
Name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ nbinstance.Manager = (*mockInstanceManager)(nil)
|
||||
|
||||
func setupTestRouter(manager nbinstance.Manager) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
AddEndpoints(manager, router)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGetInstanceStatus_SetupRequired(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: true}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/instance", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response api.InstanceStatus
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, response.SetupRequired)
|
||||
}
|
||||
|
||||
func TestGetInstanceStatus_SetupNotRequired(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: false}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/instance", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response api.InstanceStatus
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, response.SetupRequired)
|
||||
}
|
||||
|
||||
func TestGetInstanceStatus_Error(t *testing.T) {
|
||||
manager := &mockInstanceManager{
|
||||
isSetupRequiredFn: func(ctx context.Context) (bool, error) {
|
||||
return false, errors.New("database error")
|
||||
},
|
||||
}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/instance", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_Success(t *testing.T) {
|
||||
manager := &mockInstanceManager{
|
||||
isSetupRequired: true,
|
||||
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||
assert.Equal(t, "admin@example.com", email)
|
||||
assert.Equal(t, "securepassword123", password)
|
||||
assert.Equal(t, "Admin User", name)
|
||||
return &idp.UserData{
|
||||
ID: "created-user-id",
|
||||
Email: email,
|
||||
Name: name,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response api.SetupResponse
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "created-user-id", response.UserId)
|
||||
assert.Equal(t, "admin@example.com", response.Email)
|
||||
}
|
||||
|
||||
func TestSetup_AlreadyCompleted(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: false}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "admin@example.com", "password": "securepassword123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_MissingEmail(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: true}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"password": "securepassword123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_InvalidEmail(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: true}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "not-an-email", "password": "securepassword123", "name": "User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
// Note: Invalid email format uses mail.ParseAddress which is treated differently
|
||||
// and returns 400 Bad Request instead of 422 Unprocessable Entity
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_MissingPassword(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: true}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "admin@example.com", "name": "User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_PasswordTooShort(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: true}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "admin@example.com", "password": "short", "name": "User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_InvalidJSON(t *testing.T) {
|
||||
manager := &mockInstanceManager{isSetupRequired: true}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{invalid json}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_CreateUserError(t *testing.T) {
|
||||
manager := &mockInstanceManager{
|
||||
isSetupRequired: true,
|
||||
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||
return nil, errors.New("user creation failed")
|
||||
},
|
||||
}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
func TestSetup_ManagerError(t *testing.T) {
|
||||
manager := &mockInstanceManager{
|
||||
isSetupRequired: true,
|
||||
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||
return nil, status.Errorf(status.Internal, "database error")
|
||||
},
|
||||
}
|
||||
router := setupTestRouter(manager)
|
||||
|
||||
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user