mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-22 18:26:41 +00:00
[management] Legacy to embedded IdP migration tool (#5586)
This commit is contained in:
487
tools/idp-migrate/main_test.go
Normal file
487
tools/idp-migrate/main_test.go
Normal file
@@ -0,0 +1,487 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/idp/dex"
|
||||
"github.com/netbirdio/netbird/management/server/idp/migration"
|
||||
)
|
||||
|
||||
// TestMigrationServerInterface is a compile-time check that migrationServer
|
||||
// implements the migration.Server interface.
|
||||
func TestMigrationServerInterface(t *testing.T) {
|
||||
var _ migration.Server = (*migrationServer)(nil)
|
||||
}
|
||||
|
||||
func TestDecodeConnectorConfig(t *testing.T) {
|
||||
conn := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "test",
|
||||
ID: "test-id",
|
||||
Config: map[string]any{
|
||||
"issuer": "https://example.com",
|
||||
"clientID": "cid",
|
||||
"clientSecret": "csecret",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(conn)
|
||||
require.NoError(t, err)
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
result, err := decodeConnectorConfig(encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-id", result.ID)
|
||||
assert.Equal(t, "oidc", result.Type)
|
||||
assert.Equal(t, "https://example.com", result.Config["issuer"])
|
||||
}
|
||||
|
||||
func TestDecodeConnectorConfig_InvalidBase64(t *testing.T) {
|
||||
_, err := decodeConnectorConfig("not-valid-base64!!!")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "base64 decode")
|
||||
}
|
||||
|
||||
func TestDecodeConnectorConfig_InvalidJSON(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("not json"))
|
||||
_, err := decodeConnectorConfig(encoded)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "json unmarshal")
|
||||
}
|
||||
|
||||
func TestDecodeConnectorConfig_EmptyConnectorID(t *testing.T) {
|
||||
conn := dex.Connector{
|
||||
Type: "oidc",
|
||||
Name: "no-id",
|
||||
ID: "",
|
||||
}
|
||||
data, err := json.Marshal(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
_, err = decodeConnectorConfig(encoded)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "connector ID is empty")
|
||||
}
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
valid := &migrationConfig{
|
||||
configPath: "/etc/netbird/management.json",
|
||||
dataDir: "/var/lib/netbird",
|
||||
idpSeedInfo: "some-base64",
|
||||
apiURL: "https://api.example.com",
|
||||
dashboardURL: "https://dash.example.com",
|
||||
}
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
require.NoError(t, validateConfig(valid))
|
||||
})
|
||||
|
||||
t.Run("missing configPath", func(t *testing.T) {
|
||||
cfg := *valid
|
||||
cfg.configPath = ""
|
||||
err := validateConfig(&cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--config")
|
||||
})
|
||||
|
||||
t.Run("missing dataDir", func(t *testing.T) {
|
||||
cfg := *valid
|
||||
cfg.dataDir = ""
|
||||
err := validateConfig(&cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--datadir")
|
||||
})
|
||||
|
||||
t.Run("missing idpSeedInfo", func(t *testing.T) {
|
||||
cfg := *valid
|
||||
cfg.idpSeedInfo = ""
|
||||
err := validateConfig(&cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--idp-seed-info")
|
||||
})
|
||||
|
||||
t.Run("missing apiUrl", func(t *testing.T) {
|
||||
cfg := *valid
|
||||
cfg.apiURL = ""
|
||||
err := validateConfig(&cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--api-domain")
|
||||
})
|
||||
|
||||
t.Run("missing dashboardUrl", func(t *testing.T) {
|
||||
cfg := *valid
|
||||
cfg.dashboardURL = ""
|
||||
err := validateConfig(&cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--dashboard-domain")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigFromArgs_EnvVarsApplied(t *testing.T) {
|
||||
t.Run("env vars fill in for missing flags", func(t *testing.T) {
|
||||
t.Setenv("NETBIRD_CONFIG_PATH", "/env/management.json")
|
||||
t.Setenv("NETBIRD_DATA_DIR", "/env/data")
|
||||
t.Setenv("NETBIRD_IDP_SEED_INFO", "env-seed")
|
||||
t.Setenv("NETBIRD_API_URL", "https://api.env.com")
|
||||
t.Setenv("NETBIRD_DASHBOARD_URL", "https://dash.env.com")
|
||||
|
||||
cfg, err := configFromArgs([]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "/env/management.json", cfg.configPath)
|
||||
assert.Equal(t, "/env/data", cfg.dataDir)
|
||||
assert.Equal(t, "env-seed", cfg.idpSeedInfo)
|
||||
assert.Equal(t, "https://api.env.com", cfg.apiURL)
|
||||
assert.Equal(t, "https://dash.env.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("flags work without env vars", func(t *testing.T) {
|
||||
cfg, err := configFromArgs([]string{
|
||||
"--config", "/flag/management.json",
|
||||
"--datadir", "/flag/data",
|
||||
"--idp-seed-info", "flag-seed",
|
||||
"--api-url", "https://api.flag.com",
|
||||
"--dashboard-url", "https://dash.flag.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "/flag/management.json", cfg.configPath)
|
||||
assert.Equal(t, "/flag/data", cfg.dataDir)
|
||||
assert.Equal(t, "flag-seed", cfg.idpSeedInfo)
|
||||
assert.Equal(t, "https://api.flag.com", cfg.apiURL)
|
||||
assert.Equal(t, "https://dash.flag.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("env vars override flags", func(t *testing.T) {
|
||||
t.Setenv("NETBIRD_CONFIG_PATH", "/env/management.json")
|
||||
t.Setenv("NETBIRD_API_URL", "https://api.env.com")
|
||||
|
||||
cfg, err := configFromArgs([]string{
|
||||
"--config", "/flag/management.json",
|
||||
"--datadir", "/flag/data",
|
||||
"--idp-seed-info", "flag-seed",
|
||||
"--api-url", "https://api.flag.com",
|
||||
"--dashboard-url", "https://dash.flag.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "/env/management.json", cfg.configPath, "env should override flag")
|
||||
assert.Equal(t, "https://api.env.com", cfg.apiURL, "env should override flag")
|
||||
assert.Equal(t, "https://dash.flag.com", cfg.dashboardURL, "flag preserved when no env override")
|
||||
})
|
||||
|
||||
t.Run("--domain flag with specific env var override", func(t *testing.T) {
|
||||
t.Setenv("NETBIRD_API_URL", "https://api.env.com")
|
||||
|
||||
cfg, err := configFromArgs([]string{
|
||||
"--domain", "both.flag.com",
|
||||
"--config", "/path",
|
||||
"--datadir", "/data",
|
||||
"--idp-seed-info", "seed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://api.env.com", cfg.apiURL, "specific env beats --domain")
|
||||
assert.Equal(t, "both.flag.com", cfg.dashboardURL, "--domain fills dashboard")
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyOverrides_MostGranularWins(t *testing.T) {
|
||||
t.Run("specific flags beat --domain", func(t *testing.T) {
|
||||
cfg := &migrationConfig{
|
||||
apiURL: "api.specific.com",
|
||||
dashboardURL: "dash.specific.com",
|
||||
}
|
||||
applyOverrides(cfg, "broad.com")
|
||||
|
||||
assert.Equal(t, "api.specific.com", cfg.apiURL)
|
||||
assert.Equal(t, "dash.specific.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("--domain fills blanks when specific flags missing", func(t *testing.T) {
|
||||
cfg := &migrationConfig{}
|
||||
applyOverrides(cfg, "broad.com")
|
||||
|
||||
assert.Equal(t, "broad.com", cfg.apiURL)
|
||||
assert.Equal(t, "broad.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("--domain fills only the missing specific flag", func(t *testing.T) {
|
||||
cfg := &migrationConfig{
|
||||
apiURL: "api.specific.com",
|
||||
}
|
||||
applyOverrides(cfg, "broad.com")
|
||||
|
||||
assert.Equal(t, "api.specific.com", cfg.apiURL)
|
||||
assert.Equal(t, "broad.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("NETBIRD_DOMAIN overrides flags", func(t *testing.T) {
|
||||
cfg := &migrationConfig{
|
||||
apiURL: "api.flag.com",
|
||||
dashboardURL: "dash.flag.com",
|
||||
}
|
||||
t.Setenv("NETBIRD_DOMAIN", "env-broad.com")
|
||||
|
||||
applyOverrides(cfg, "")
|
||||
|
||||
assert.Equal(t, "env-broad.com", cfg.apiURL)
|
||||
assert.Equal(t, "env-broad.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("specific env vars beat NETBIRD_DOMAIN", func(t *testing.T) {
|
||||
cfg := &migrationConfig{}
|
||||
t.Setenv("NETBIRD_DOMAIN", "env-broad.com")
|
||||
t.Setenv("NETBIRD_API_URL", "api.env-specific.com")
|
||||
t.Setenv("NETBIRD_DASHBOARD_URL", "dash.env-specific.com")
|
||||
|
||||
applyOverrides(cfg, "")
|
||||
|
||||
assert.Equal(t, "api.env-specific.com", cfg.apiURL)
|
||||
assert.Equal(t, "dash.env-specific.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("one specific env var overrides only its field", func(t *testing.T) {
|
||||
cfg := &migrationConfig{}
|
||||
t.Setenv("NETBIRD_DOMAIN", "env-broad.com")
|
||||
t.Setenv("NETBIRD_API_URL", "api.env-specific.com")
|
||||
|
||||
applyOverrides(cfg, "")
|
||||
|
||||
assert.Equal(t, "api.env-specific.com", cfg.apiURL)
|
||||
assert.Equal(t, "env-broad.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("specific env vars beat all flags combined", func(t *testing.T) {
|
||||
cfg := &migrationConfig{
|
||||
apiURL: "api.flag.com",
|
||||
dashboardURL: "dash.flag.com",
|
||||
}
|
||||
t.Setenv("NETBIRD_API_URL", "api.env.com")
|
||||
t.Setenv("NETBIRD_DASHBOARD_URL", "dash.env.com")
|
||||
|
||||
applyOverrides(cfg, "domain-flag.com")
|
||||
|
||||
assert.Equal(t, "api.env.com", cfg.apiURL)
|
||||
assert.Equal(t, "dash.env.com", cfg.dashboardURL)
|
||||
})
|
||||
|
||||
t.Run("env vars override all non-domain flags", func(t *testing.T) {
|
||||
cfg := &migrationConfig{
|
||||
configPath: "/flag/path",
|
||||
dataDir: "/flag/data",
|
||||
idpSeedInfo: "flag-seed",
|
||||
dryRun: false,
|
||||
force: false,
|
||||
skipConfig: false,
|
||||
skipPopulateUserInfo: false,
|
||||
logLevel: "info",
|
||||
}
|
||||
t.Setenv("NETBIRD_CONFIG_PATH", "/env/path")
|
||||
t.Setenv("NETBIRD_DATA_DIR", "/env/data")
|
||||
t.Setenv("NETBIRD_IDP_SEED_INFO", "env-seed")
|
||||
t.Setenv("NETBIRD_DRY_RUN", "true")
|
||||
t.Setenv("NETBIRD_FORCE", "true")
|
||||
t.Setenv("NETBIRD_SKIP_CONFIG", "true")
|
||||
t.Setenv("NETBIRD_SKIP_POPULATE_USER_INFO", "true")
|
||||
t.Setenv("NETBIRD_LOG_LEVEL", "debug")
|
||||
|
||||
applyOverrides(cfg, "")
|
||||
|
||||
assert.Equal(t, "/env/path", cfg.configPath)
|
||||
assert.Equal(t, "/env/data", cfg.dataDir)
|
||||
assert.Equal(t, "env-seed", cfg.idpSeedInfo)
|
||||
assert.True(t, cfg.dryRun)
|
||||
assert.True(t, cfg.force)
|
||||
assert.True(t, cfg.skipConfig)
|
||||
assert.True(t, cfg.skipPopulateUserInfo)
|
||||
assert.Equal(t, "debug", cfg.logLevel)
|
||||
})
|
||||
|
||||
t.Run("boolean env vars properly parse false values", func(t *testing.T) {
|
||||
cfg := &migrationConfig{}
|
||||
t.Setenv("NETBIRD_DRY_RUN", "false")
|
||||
t.Setenv("NETBIRD_FORCE", "yes")
|
||||
t.Setenv("NETBIRD_SKIP_CONFIG", "0")
|
||||
|
||||
applyOverrides(cfg, "")
|
||||
|
||||
assert.False(t, cfg.dryRun)
|
||||
assert.False(t, cfg.force)
|
||||
assert.False(t, cfg.skipConfig)
|
||||
})
|
||||
|
||||
t.Run("unset env vars do not override flags", func(t *testing.T) {
|
||||
cfg := &migrationConfig{
|
||||
configPath: "/flag/path",
|
||||
dataDir: "/flag/data",
|
||||
idpSeedInfo: "flag-seed",
|
||||
dryRun: true,
|
||||
logLevel: "warn",
|
||||
}
|
||||
|
||||
applyOverrides(cfg, "")
|
||||
|
||||
assert.Equal(t, "/flag/path", cfg.configPath)
|
||||
assert.Equal(t, "/flag/data", cfg.dataDir)
|
||||
assert.Equal(t, "flag-seed", cfg.idpSeedInfo)
|
||||
assert.True(t, cfg.dryRun)
|
||||
assert.Equal(t, "warn", cfg.logLevel)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildUrl(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"with https scheme", "https://example.com", "/oauth2", "https://example.com/oauth2"},
|
||||
{"with http scheme", "http://example.com", "/oauth2/callback", "http://example.com/oauth2/callback"},
|
||||
{"bare domain", "example.com", "/oauth2", "https://example.com/oauth2"},
|
||||
{"domain with port", "example.com:8080", "/nb-auth", "https://example.com:8080/nb-auth"},
|
||||
{"trailing slash on uri", "https://example.com/", "/oauth2", "https://example.com/oauth2"},
|
||||
{"nested path", "https://example.com", "/oauth2/callback", "https://example.com/oauth2/callback"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
url, err := buildURL(tt.uri, tt.path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
t.Run("generates valid config", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "management.json")
|
||||
|
||||
originalConfig := `{
|
||||
"Datadir": "/var/lib/netbird",
|
||||
"HttpConfig": {
|
||||
"LetsEncryptDomain": "mgmt.example.com",
|
||||
"CertFile": "/etc/ssl/cert.pem",
|
||||
"CertKey": "/etc/ssl/key.pem",
|
||||
"AuthIssuer": "https://zitadel.example.com/oauth2",
|
||||
"AuthKeysLocation": "https://zitadel.example.com/oauth2/keys",
|
||||
"OIDCConfigEndpoint": "https://zitadel.example.com/.well-known/openid-configuration",
|
||||
"AuthClientID": "old-client-id",
|
||||
"AuthUserIDClaim": "preferred_username"
|
||||
},
|
||||
"IdpManagerConfig": {
|
||||
"ManagerType": "zitadel",
|
||||
"ClientConfig": {
|
||||
"Issuer": "https://zitadel.example.com",
|
||||
"ClientID": "zit-id",
|
||||
"ClientSecret": "zit-secret"
|
||||
}
|
||||
}
|
||||
}`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(originalConfig), 0o600))
|
||||
|
||||
cfg := &migrationConfig{
|
||||
configPath: configPath,
|
||||
dashboardURL: "https://mgmt.example.com",
|
||||
apiURL: "https://mgmt.example.com",
|
||||
}
|
||||
conn := &dex.Connector{
|
||||
Type: "zitadel",
|
||||
Name: "zitadel",
|
||||
ID: "zitadel",
|
||||
Config: map[string]any{
|
||||
"issuer": "https://zitadel.example.com",
|
||||
"clientID": "zit-id",
|
||||
"clientSecret": "zit-secret",
|
||||
},
|
||||
}
|
||||
|
||||
err := generateConfig(cfg, conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check backup was created
|
||||
backupPath := configPath + ".bak"
|
||||
backupData, err := os.ReadFile(backupPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalConfig, string(backupData))
|
||||
|
||||
// Read and parse the new config
|
||||
newData, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(newData, &result))
|
||||
|
||||
// IdpManagerConfig should be removed
|
||||
_, hasOldIdp := result["IdpManagerConfig"]
|
||||
assert.False(t, hasOldIdp, "IdpManagerConfig should be removed")
|
||||
|
||||
_, hasPKCE := result["PKCEAuthorizationFlow"]
|
||||
assert.False(t, hasPKCE, "PKCEAuthorizationFlow should be removed")
|
||||
|
||||
// EmbeddedIdP should be present with minimal fields
|
||||
embeddedIDP, ok := result["EmbeddedIdP"].(map[string]any)
|
||||
require.True(t, ok, "EmbeddedIdP should be present")
|
||||
assert.Equal(t, true, embeddedIDP["Enabled"])
|
||||
assert.Equal(t, "https://mgmt.example.com/oauth2", embeddedIDP["Issuer"])
|
||||
assert.Nil(t, embeddedIDP["LocalAuthDisabled"], "LocalAuthDisabled should not be set")
|
||||
assert.Nil(t, embeddedIDP["SignKeyRefreshEnabled"], "SignKeyRefreshEnabled should not be set")
|
||||
assert.Nil(t, embeddedIDP["CLIRedirectURIs"], "CLIRedirectURIs should not be set")
|
||||
|
||||
// Static connector's redirectURI should use the management domain
|
||||
connectors := embeddedIDP["StaticConnectors"].([]any)
|
||||
require.Len(t, connectors, 1)
|
||||
firstConn := connectors[0].(map[string]any)
|
||||
connCfg := firstConn["config"].(map[string]any)
|
||||
assert.Equal(t, "https://mgmt.example.com/oauth2/callback", connCfg["redirectURI"],
|
||||
"redirectURI should be overridden to use the management domain")
|
||||
|
||||
// HttpConfig should only have CertFile and CertKey
|
||||
httpConfig, ok := result["HttpConfig"].(map[string]any)
|
||||
require.True(t, ok, "HttpConfig should be present")
|
||||
assert.Equal(t, "/etc/ssl/cert.pem", httpConfig["CertFile"])
|
||||
assert.Equal(t, "/etc/ssl/key.pem", httpConfig["CertKey"])
|
||||
assert.Nil(t, httpConfig["AuthIssuer"], "AuthIssuer should be stripped")
|
||||
|
||||
// Datadir should be preserved
|
||||
assert.Equal(t, "/var/lib/netbird", result["Datadir"])
|
||||
})
|
||||
|
||||
t.Run("dry run does not write files", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "management.json")
|
||||
|
||||
originalConfig := `{"HttpConfig": {"CertFile": "", "CertKey": ""}}`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(originalConfig), 0o600))
|
||||
|
||||
cfg := &migrationConfig{
|
||||
configPath: configPath,
|
||||
dashboardURL: "https://mgmt.example.com",
|
||||
apiURL: "https://mgmt.example.com",
|
||||
dryRun: true,
|
||||
}
|
||||
conn := &dex.Connector{Type: "oidc", Name: "test", ID: "test"}
|
||||
|
||||
err := generateConfig(cfg, conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Original should be unchanged
|
||||
data, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalConfig, string(data))
|
||||
|
||||
// No backup should exist
|
||||
_, err = os.Stat(configPath + ".bak")
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user