mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
488 lines
15 KiB
Go
488 lines
15 KiB
Go
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))
|
|
})
|
|
}
|