mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-19 21:29:09 +02:00
The shared loader changes how each service reads environment variables, flags and configuration files. These tests assert the behavior main produced for concrete inputs (env names and aliases, empty and invalid values, list parsing, flag precedence, file decoding shapes, key persistence), so every intentional break shows up as a failing test that can be reviewed and either restored or documented.
1854 lines
69 KiB
Go
1854 lines
69 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/pflag"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
|
)
|
|
|
|
func TestLoadManagementConfigUsesDefaultDataDirForEmptyValue(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"Datadir":""}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, defaultMgmtDataDir, cfg.Datadir, "Empty legacy values should use the default data directory")
|
|
}
|
|
|
|
func TestLoadManagementConfigPreservesExtraConfigKeyCase(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"IdpManagerConfig": {
|
|
"ExtraConfig": {
|
|
"ServiceAccountKey": "service-account-key",
|
|
"CustomerId": "customer-id"
|
|
}
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg.IdpManagerConfig, "IDP configuration should be decoded")
|
|
assert.Equal(t, map[string]string{
|
|
"ServiceAccountKey": "service-account-key",
|
|
"CustomerId": "customer-id",
|
|
}, map[string]string(cfg.IdpManagerConfig.ExtraConfig), "IDP-specific keys should retain their case")
|
|
}
|
|
|
|
func TestLoadManagementConfigPreservesEmptyHTTPConfig(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"HttpConfig":{}}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, cfg.HttpConfig, "An explicitly configured empty HTTP section should remain present")
|
|
}
|
|
|
|
func TestLoadManagementConfigPreservesJSONShapes(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "mixed case struct field",
|
|
contents: `{"dAtAdIr":"/mixed-case"}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "/mixed-case", cfg.Datadir, "JSON struct fields should remain case-insensitive")
|
|
},
|
|
},
|
|
{
|
|
name: "empty nested pointer",
|
|
contents: `{"IdpManagerConfig":{"ClientConfig":{}}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.IdpManagerConfig, "IDP configuration should remain present")
|
|
assert.NotNil(t, cfg.IdpManagerConfig.ClientConfig,
|
|
"An explicitly configured empty nested pointer should remain present")
|
|
},
|
|
},
|
|
{
|
|
name: "empty map",
|
|
contents: `{"IdpManagerConfig":{"ExtraConfig":{}}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.IdpManagerConfig, "IDP configuration should remain present")
|
|
assert.NotNil(t, cfg.IdpManagerConfig.ExtraConfig,
|
|
"An explicitly configured empty map should remain non-nil")
|
|
},
|
|
},
|
|
{
|
|
name: "empty slice",
|
|
contents: `{"Stuns":[]}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.NotNil(t, cfg.Stuns, "An explicitly configured empty slice should remain non-nil")
|
|
assert.Empty(t, cfg.Stuns, "An explicitly configured empty slice should remain empty")
|
|
},
|
|
},
|
|
{
|
|
name: "empty account map",
|
|
contents: `{"PerAccountHighestSupportedSyncMessageVersion":{}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.NotNil(t, cfg.PerAccountHighestSupportedSyncMessageVersion,
|
|
"An explicitly configured empty account map should remain non-nil")
|
|
},
|
|
},
|
|
{
|
|
name: "null pointer",
|
|
contents: `{"HttpConfig":null}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.HttpConfig, "A null pointer should remain nil")
|
|
},
|
|
},
|
|
{
|
|
name: "unknown field",
|
|
contents: `{"Datadir":"/known-data","UnknownSetting":true}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "/known-data", cfg.Datadir, "Unknown JSON fields should remain ignored")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadAdminMgmtConfigPreservesLegacyEmptyDataDir(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
|
|
|
|
oldConfigPath := nbconfig.MgmtConfigPath
|
|
oldAdminDatadir := adminDatadir
|
|
nbconfig.MgmtConfigPath = configPath
|
|
adminDatadir = ""
|
|
t.Cleanup(func() {
|
|
nbconfig.MgmtConfigPath = oldConfigPath
|
|
adminDatadir = oldAdminDatadir
|
|
})
|
|
|
|
cfg, datadir, err := loadAdminMgmtConfig(context.Background(), false)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cfg.Datadir, "Admin commands should retain the legacy empty data directory")
|
|
assert.Empty(t, datadir, "Admin commands should retain the legacy empty effective data directory")
|
|
}
|
|
|
|
func TestEnsureEncryptionKeyDoesNotPersistUnreferencedEnvironmentSecrets(t *testing.T) {
|
|
const environmentSecret = "environment-client-secret"
|
|
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("NB_IDPMANAGERCONFIG_CLIENTCONFIG_CLIENTSECRET", environmentSecret)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"IdpManagerConfig": {
|
|
"ClientConfig": {}
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, EnsureEncryptionKey(context.Background(), configPath, cfg))
|
|
|
|
persistedConfig, err := os.ReadFile(configPath)
|
|
require.NoError(t, err)
|
|
assert.False(t, bytes.Contains(persistedConfig, []byte(environmentSecret)),
|
|
"An environment value absent from the file should not be persisted")
|
|
}
|
|
|
|
func TestEnsureEncryptionKeyPreservesTemplateExpandedValues(t *testing.T) {
|
|
const templateSecret = "template-client-secret"
|
|
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("MANAGEMENT_TEMPLATE_SECRET", templateSecret)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"IdpManagerConfig": {
|
|
"ClientConfig": {
|
|
"ClientSecret": "{{ .MANAGEMENT_TEMPLATE_SECRET }}"
|
|
}
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, EnsureEncryptionKey(context.Background(), configPath, cfg))
|
|
|
|
persistedConfig, err := os.ReadFile(configPath)
|
|
require.NoError(t, err)
|
|
assert.True(t, bytes.Contains(persistedConfig, []byte(templateSecret)),
|
|
"Template-expanded values were historically persisted during key generation")
|
|
}
|
|
|
|
func TestLoadMgmtConfigPreservesLegacyDataDirPrecedence(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"Datadir": "/stale-config-directory",
|
|
"DataStoreEncryptionKey": "configured-key",
|
|
"HttpConfig": {
|
|
"AuthAudience": "test-audience"
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := LoadMgmtConfig(context.Background(), configPath, unchangedManagementFlags())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, defaultMgmtDataDir, cfg.Datadir,
|
|
"The historical command default should continue to override a stale config value")
|
|
}
|
|
|
|
func TestApplyCommandLineOverridesPreservesLegacyEmptyValues(t *testing.T) {
|
|
oldDatadir := mgmtDataDir
|
|
oldLetsencryptDomain := mgmtLetsencryptDomain
|
|
oldCertFile := certFile
|
|
oldCertKey := certKey
|
|
t.Cleanup(func() {
|
|
mgmtDataDir = oldDatadir
|
|
mgmtLetsencryptDomain = oldLetsencryptDomain
|
|
certFile = oldCertFile
|
|
certKey = oldCertKey
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
configure func(*testing.T, *pflag.FlagSet)
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "empty datadir",
|
|
configure: func(t *testing.T, flags *pflag.FlagSet) {
|
|
mgmtDataDir = ""
|
|
require.NoError(t, flags.Set("datadir", ""))
|
|
},
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "/file-data", cfg.Datadir, "An empty datadir flag should not clear the file value")
|
|
},
|
|
},
|
|
{
|
|
name: "empty letsencrypt domain",
|
|
configure: func(t *testing.T, flags *pflag.FlagSet) {
|
|
mgmtLetsencryptDomain = ""
|
|
require.NoError(t, flags.Set("letsencrypt-domain", ""))
|
|
},
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "file.example.com", cfg.HttpConfig.LetsEncryptDomain,
|
|
"An empty letsencrypt flag should not clear the file value")
|
|
},
|
|
},
|
|
{
|
|
name: "empty certificate pair",
|
|
configure: func(t *testing.T, flags *pflag.FlagSet) {
|
|
certFile = ""
|
|
certKey = ""
|
|
require.NoError(t, flags.Set("cert-file", ""))
|
|
require.NoError(t, flags.Set("cert-key", ""))
|
|
},
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "/file/tls.crt", cfg.HttpConfig.CertFile,
|
|
"An empty certificate pair should not clear the file certificate")
|
|
assert.Equal(t, "/file/tls.key", cfg.HttpConfig.CertKey,
|
|
"An empty certificate pair should not clear the file key")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
mgmtDataDir = defaultMgmtDataDir
|
|
mgmtLetsencryptDomain = ""
|
|
certFile = ""
|
|
certKey = ""
|
|
flags := unchangedManagementFlags()
|
|
test.configure(t, flags)
|
|
cfg := &nbconfig.Config{
|
|
Datadir: "/file-data",
|
|
HttpConfig: &nbconfig.HttpServerConfig{
|
|
LetsEncryptDomain: "file.example.com",
|
|
CertFile: "/file/tls.crt",
|
|
CertKey: "/file/tls.key",
|
|
},
|
|
}
|
|
|
|
ApplyCommandLineOverrides(cfg, flags)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManagementPortDefaultsRemainCompatible(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
httpConfig string
|
|
flags map[string]string
|
|
expected int
|
|
}{
|
|
{
|
|
name: "no TLS",
|
|
httpConfig: `{"AuthAudience":"test-audience"}`,
|
|
expected: 80,
|
|
},
|
|
{
|
|
name: "file-only letsencrypt",
|
|
httpConfig: `{"AuthAudience":"test-audience","LetsEncryptDomain":"management.example.com"}`,
|
|
expected: 80,
|
|
},
|
|
{
|
|
name: "file certificate pair",
|
|
httpConfig: `{"AuthAudience":"test-audience","CertFile":"/tls.crt","CertKey":"/tls.key"}`,
|
|
expected: 443,
|
|
},
|
|
{
|
|
name: "letsencrypt flag",
|
|
httpConfig: `{"AuthAudience":"test-audience"}`,
|
|
flags: map[string]string{"letsencrypt-domain": "management.example.com"},
|
|
expected: 443,
|
|
},
|
|
{
|
|
name: "explicit zero port",
|
|
httpConfig: `{"AuthAudience":"test-audience"}`,
|
|
flags: map[string]string{"port": "0"},
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "explicit nonzero port",
|
|
httpConfig: `{"AuthAudience":"test-audience"}`,
|
|
flags: map[string]string{"port": "10002"},
|
|
expected: 10002,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
actual := runManagementPreRun(t, test.httpConfig, test.flags)
|
|
assert.Equal(t, test.expected, actual, "Management implicit port selection should retain legacy behavior")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLegacyManagementFlagsRemainRegistered(t *testing.T) {
|
|
for _, name := range []string{
|
|
"port",
|
|
"disable-legacy-port",
|
|
"metrics-port",
|
|
"datadir",
|
|
"config",
|
|
"letsencrypt-domain",
|
|
"single-account-mode-domain",
|
|
"disable-single-account-mode",
|
|
"cert-file",
|
|
"cert-key",
|
|
"disable-anonymous-metrics",
|
|
"dns-domain",
|
|
idpSignKeyRefreshEnabledFlagName,
|
|
"user-delete-from-idp",
|
|
"disable-geolite-update",
|
|
} {
|
|
assert.NotNil(t, mgmtCmd.Flags().Lookup(name), "Legacy Management flag %s should remain registered", name)
|
|
}
|
|
for _, name := range []string{"log-level", "log-file"} {
|
|
assert.NotNil(t, rootCmd.PersistentFlags().Lookup(name),
|
|
"Legacy persistent Management flag %s should remain registered", name)
|
|
}
|
|
}
|
|
|
|
func TestLoadManagementConfigSources(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("MANAGEMENT_DATA_DIR", "/template-data")
|
|
t.Setenv("MANAGEMENT_ENCRYPTION_KEY", "template-key")
|
|
t.Setenv("NB_DATADIR", "/environment-data")
|
|
t.Setenv("NB_HTTPCONFIG_AUTHAUDIENCE", "environment-audience")
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"Datadir": "{{ .MANAGEMENT_DATA_DIR }}",
|
|
"DataStoreEncryptionKey": "{{ .MANAGEMENT_ENCRYPTION_KEY }}",
|
|
"HttpConfig": {
|
|
"AuthAudience": "file-audience"
|
|
},
|
|
"TURNConfig": {
|
|
"CredentialsTTL": "1h"
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/environment-data", cfg.Datadir, "Bound environment values should override template values")
|
|
assert.Equal(t, "template-key", cfg.DataStoreEncryptionKey, "Template environment values should be expanded")
|
|
require.NotNil(t, cfg.HttpConfig, "Nested configuration should be decoded")
|
|
assert.Equal(t, "environment-audience", cfg.HttpConfig.AuthAudience, "Bound environment values should override the file")
|
|
require.NotNil(t, cfg.TURNConfig, "TURN configuration should be decoded")
|
|
assert.Equal(t, time.Hour, cfg.TURNConfig.CredentialsTTL.Duration, "JSON duration types should be decoded")
|
|
|
|
datadirFlag := mgmtCmd.Flags().Lookup("datadir")
|
|
oldDatadir := datadirFlag.Value.String()
|
|
oldChanged := datadirFlag.Changed
|
|
t.Cleanup(func() {
|
|
require.NoError(t, datadirFlag.Value.Set(oldDatadir))
|
|
datadirFlag.Changed = oldChanged
|
|
})
|
|
require.NoError(t, datadirFlag.Value.Set("/flag-data"))
|
|
datadirFlag.Changed = true
|
|
ApplyCommandLineOverrides(cfg, mgmtCmd.Flags())
|
|
assert.Equal(t, "/flag-data", cfg.Datadir, "Flags should override environment values")
|
|
}
|
|
|
|
// management-01: environment variables were never read directly by the legacy loader.
|
|
func TestLoadManagementConfigPreservesLegacyFileValuesOverEnvironment(t *testing.T) {
|
|
const templateAudience = "template-audience"
|
|
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("MANAGEMENT_TEMPLATE_AUDIENCE", templateAudience)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environment map[string]string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "http audience",
|
|
environment: map[string]string{"NB_HTTPCONFIG_AUTHAUDIENCE": "env-aud"},
|
|
contents: `{"HttpConfig":{"AuthAudience":"file-aud"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HttpConfig, "HTTP configuration should be decoded")
|
|
assert.Equal(t, "file-aud", cfg.HttpConfig.AuthAudience,
|
|
"The legacy loader never read NB_ environment variables; the file value must win")
|
|
},
|
|
},
|
|
{
|
|
name: "relay secret",
|
|
environment: map[string]string{"NB_RELAY_SECRET": "env-secret"},
|
|
contents: `{"Relay":{"Addresses":["rel://a"],"Secret":"file-secret"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Relay, "Relay configuration should be decoded")
|
|
assert.Equal(t, "file-secret", cfg.Relay.Secret,
|
|
"The legacy loader never read NB_ environment variables; the file value must win")
|
|
},
|
|
},
|
|
{
|
|
name: "turn secret",
|
|
environment: map[string]string{"NB_TURNCONFIG_SECRET": "env-secret"},
|
|
contents: `{"TURNConfig":{"Secret":"file-secret"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.TURNConfig, "TURN configuration should be decoded")
|
|
assert.Equal(t, "file-secret", cfg.TURNConfig.Secret,
|
|
"The legacy loader never read NB_ environment variables; the file value must win")
|
|
},
|
|
},
|
|
{
|
|
name: "idp client secret",
|
|
environment: map[string]string{"NB_IDPMANAGERCONFIG_CLIENTCONFIG_CLIENTSECRET": "env-secret"},
|
|
contents: `{"IdpManagerConfig":{"ClientConfig":{"ClientSecret":"file-secret"}}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.IdpManagerConfig, "IDP configuration should be decoded")
|
|
require.NotNil(t, cfg.IdpManagerConfig.ClientConfig, "IDP client configuration should be decoded")
|
|
assert.Equal(t, "file-secret", cfg.IdpManagerConfig.ClientConfig.ClientSecret,
|
|
"The legacy loader never read NB_ environment variables; the file value must win")
|
|
},
|
|
},
|
|
{
|
|
name: "sign key refresh",
|
|
environment: map[string]string{"NB_HTTPCONFIG_IDPSIGNKEYREFRESHENABLED": "true"},
|
|
contents: `{"HttpConfig":{"IdpSignKeyRefreshEnabled":false}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HttpConfig, "HTTP configuration should be decoded")
|
|
assert.False(t, cfg.HttpConfig.IdpSignKeyRefreshEnabled,
|
|
"Only the --idp-sign-key-refresh-enabled flag could toggle the refresh setting outside the file")
|
|
},
|
|
},
|
|
{
|
|
name: "embedded idp storage dsn",
|
|
environment: map[string]string{"NB_EMBEDDEDIDP_STORAGE_CONFIG_DSN": "env-dsn"},
|
|
contents: `{"EmbeddedIdP":{"Enabled":true,"Storage":{"Type":"postgres","Config":{"DSN":"file-dsn"}}}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.EmbeddedIdP, "Embedded IdP configuration should be decoded")
|
|
assert.Equal(t, "file-dsn", cfg.EmbeddedIdP.Storage.Config.DSN,
|
|
"The legacy loader never read NB_ environment variables; the file value must win")
|
|
},
|
|
},
|
|
{
|
|
name: "template expanded value",
|
|
environment: map[string]string{"NB_HTTPCONFIG_AUTHAUDIENCE": "env-aud"},
|
|
contents: `{"HttpConfig":{"AuthAudience":"{{ .MANAGEMENT_TEMPLATE_AUDIENCE }}"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HttpConfig, "HTTP configuration should be decoded")
|
|
assert.Equal(t, templateAudience, cfg.HttpConfig.AuthAudience,
|
|
"Template references were the only supported environment input and must win over NB_ variables")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
for name, value := range test.environment {
|
|
t.Setenv(name, value)
|
|
}
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-02 / management-03: the encryption key came only from the file and the file
|
|
// was only rewritten when it contained no key at all.
|
|
func TestLoadManagementConfigPreservesLegacyFileEncryptionKey(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentKey string
|
|
}{
|
|
{name: "empty environment key", environmentKey: ""},
|
|
{name: "different environment key", environmentKey: "environment-key"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv("NB_DATASTOREENCRYPTIONKEY", test.environmentKey)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
contents := []byte(`{"DataStoreEncryptionKey":"operator-key","HttpConfig":{"AuthAudience":"file-aud"}}`)
|
|
require.NoError(t, os.WriteFile(configPath, contents, 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "operator-key", cfg.DataStoreEncryptionKey,
|
|
"NB_DATASTOREENCRYPTIONKEY was never read; the file key must be used")
|
|
|
|
require.NoError(t, EnsureEncryptionKey(context.Background(), configPath, cfg))
|
|
assert.Equal(t, "operator-key", cfg.DataStoreEncryptionKey,
|
|
"EnsureEncryptionKey must not replace an operator-provided key")
|
|
persistedConfig, err := os.ReadFile(configPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(contents), string(persistedConfig),
|
|
"The config file must not be rewritten when it already contains an encryption key")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-04 / management-05: implicit TLS port selection only considered the
|
|
// --letsencrypt-domain flag and the certificate pair from the file.
|
|
func TestManagementPortDefaultsIgnoreEnvironmentTLS(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
environment map[string]string
|
|
expected int
|
|
}{
|
|
{
|
|
name: "letsencrypt domain from environment",
|
|
environment: map[string]string{"NB_HTTPCONFIG_LETSENCRYPTDOMAIN": "management.example.com"},
|
|
expected: 80,
|
|
},
|
|
{
|
|
name: "certificate pair from environment",
|
|
environment: map[string]string{
|
|
"NB_HTTPCONFIG_CERTFILE": "/tls.crt",
|
|
"NB_HTTPCONFIG_CERTKEY": "/tls.key",
|
|
},
|
|
expected: 80,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
actual := runManagementPreRunWithEnvironment(t, `{"AuthAudience":"test-audience"}`, nil, test.environment)
|
|
assert.Equal(t, test.expected, actual,
|
|
"Environment variables never enabled TLS, so the implicit port must remain the plain HTTP default")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-06: an empty-but-set environment variable was ignored and never cleared file values.
|
|
func TestLoadManagementConfigPreservesLegacyFileValuesOverEmptyEnvironment(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "http audience",
|
|
environmentName: "NB_HTTPCONFIG_AUTHAUDIENCE",
|
|
contents: `{"HttpConfig":{"AuthAudience":"file-aud"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HttpConfig, "HTTP configuration should be decoded")
|
|
assert.Equal(t, "file-aud", cfg.HttpConfig.AuthAudience,
|
|
"An empty NB_ variable was ignored and must not clear the file value")
|
|
},
|
|
},
|
|
{
|
|
name: "relay addresses",
|
|
environmentName: "NB_RELAY_ADDRESSES",
|
|
contents: `{"Relay":{"Addresses":["rel://a"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Relay, "Relay configuration should be decoded")
|
|
assert.Equal(t, []string{"rel://a"}, cfg.Relay.Addresses,
|
|
"An empty NB_ variable was ignored and must not empty the relay list")
|
|
},
|
|
},
|
|
{
|
|
name: "stuns",
|
|
environmentName: "NB_STUNS",
|
|
contents: `{"Stuns":[{"Proto":"udp","URI":"stun:stun.example.com:3478"}]}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Len(t, cfg.Stuns, 1, "An empty NB_ variable was ignored and must not empty the STUN list")
|
|
},
|
|
},
|
|
{
|
|
name: "trusted proxies",
|
|
environmentName: "NB_REVERSEPROXY_TRUSTEDHTTPPROXIES",
|
|
contents: `{"ReverseProxy":{"TrustedHTTPProxies":["10.0.0.0/8"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Len(t, cfg.ReverseProxy.TrustedHTTPProxies, 1,
|
|
"An empty NB_ variable was ignored and must not empty the trusted proxy list")
|
|
},
|
|
},
|
|
{
|
|
name: "datadir",
|
|
environmentName: "NB_DATADIR",
|
|
contents: `{"Datadir":"/file-data"}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "/file-data", cfg.Datadir,
|
|
"An empty NB_ variable was ignored and must not discard the file data directory")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, "")
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-07: NB_DATADIR had no effect on either the server or the admin commands.
|
|
func TestLoadMgmtConfigIgnoresEnvironmentDataDir(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("NB_DATADIR", "/env-data")
|
|
|
|
oldDatadir := mgmtDataDir
|
|
mgmtDataDir = defaultMgmtDataDir
|
|
t.Cleanup(func() { mgmtDataDir = oldDatadir })
|
|
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"DataStoreEncryptionKey": "configured-key",
|
|
"HttpConfig": {
|
|
"AuthAudience": "test-audience"
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := LoadMgmtConfig(context.Background(), configPath, unchangedManagementFlags())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, defaultMgmtDataDir, cfg.Datadir,
|
|
"The server data directory came from the --datadir flag default; NB_DATADIR was never read")
|
|
}
|
|
|
|
func TestLoadAdminMgmtConfigIgnoresEnvironmentDataDir(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("NB_DATADIR", "/env-data")
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"Datadir":"/file-data"}`), 0o600))
|
|
setAdminConfigPath(t, configPath)
|
|
|
|
cfg, datadir, err := loadAdminMgmtConfig(context.Background(), false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/file-data", cfg.Datadir, "Admin commands read the data directory from the file only")
|
|
assert.Equal(t, "/file-data", datadir, "Admin commands read the effective data directory from the file only")
|
|
}
|
|
|
|
// management-08: unparsable environment values were ignored instead of aborting startup.
|
|
func TestLoadManagementConfigIgnoresInvalidScalarEnvironmentValues(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
value string
|
|
}{
|
|
{name: "invalid bool", environmentName: "NB_DISABLEDEFAULTPOLICY", value: "maybe"},
|
|
{name: "padded bool", environmentName: "NB_DISABLEDEFAULTPOLICY", value: " true"},
|
|
{name: "invalid int", environmentName: "NB_HIGHESTSUPPORTEDSYNCMESSAGEVERSION", value: "abc"},
|
|
{name: "float int", environmentName: "NB_HIGHESTSUPPORTEDSYNCMESSAGEVERSION", value: "1.0"},
|
|
{name: "negative uint", environmentName: "NB_REVERSEPROXY_TRUSTEDHTTPPROXIESCOUNT", value: "-1"},
|
|
{name: "empty duration", environmentName: "NB_TURNCONFIG_CREDENTIALSTTL", value: ""},
|
|
{name: "invalid duration", environmentName: "NB_TURNCONFIG_CREDENTIALSTTL", value: "abc"},
|
|
{name: "numeric duration", environmentName: "NB_TURNCONFIG_CREDENTIALSTTL", value: "3600000000000"},
|
|
{name: "invalid prefix", environmentName: "NB_REVERSEPROXY_TRUSTEDHTTPPROXIES", value: "bad"},
|
|
{name: "address instead of prefix", environmentName: "NB_REVERSEPROXY_TRUSTEDHTTPPROXIES", value: "10.0.0.1"},
|
|
{name: "overflowing login flag", environmentName: "NB_PKCEAUTHORIZATIONFLOW_PROVIDERCONFIG_LOGINFLAG", value: "300"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"Datadir": "/file-data",
|
|
"TURNConfig": {"CredentialsTTL": "1h"},
|
|
"PKCEAuthorizationFlow": {"ProviderConfig": {"LoginFlag": 1}}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err, "Environment variables were never read, so an unparsable value must not fail startup")
|
|
assert.Equal(t, "/file-data", cfg.Datadir, "File values should be used when the environment is ignored")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-09: a variable named after a configuration section had no effect.
|
|
func TestLoadManagementConfigIgnoresSectionEnvironmentVariables(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
value string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "http config",
|
|
environmentName: "NB_HTTPCONFIG",
|
|
value: "x",
|
|
contents: `{"HttpConfig":{"AuthAudience":"file-aud"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HttpConfig, "A section-named variable was ignored and must not drop the HTTP section")
|
|
assert.Equal(t, "file-aud", cfg.HttpConfig.AuthAudience, "The HTTP section should be loaded from the file")
|
|
},
|
|
},
|
|
{
|
|
name: "relay",
|
|
environmentName: "NB_RELAY",
|
|
value: "x",
|
|
contents: `{"Relay":{"Addresses":["rel://a"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Relay, "A section-named variable was ignored and must not drop the relay section")
|
|
assert.Equal(t, []string{"rel://a"}, cfg.Relay.Addresses, "The relay section should be loaded from the file")
|
|
},
|
|
},
|
|
{
|
|
name: "empty idp manager config",
|
|
environmentName: "NB_IDPMANAGERCONFIG",
|
|
value: "",
|
|
contents: `{"IdpManagerConfig":{"ManagerType":"none"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.IdpManagerConfig, "A section-named variable was ignored and must not drop the IDP section")
|
|
assert.Equal(t, "none", cfg.IdpManagerConfig.ManagerType, "The IDP section should be loaded from the file")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-10: the optional embedded IdP section existed only when present in the file.
|
|
func TestLoadManagementConfigDoesNotMaterializeEmbeddedIdPFromEnvironment(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
value string
|
|
}{
|
|
{name: "enabled", environmentName: "NB_EMBEDDEDIDP_ENABLED", value: "true"},
|
|
{name: "empty issuer", environmentName: "NB_EMBEDDEDIDP_ISSUER", value: ""},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"HttpConfig":{"AuthAudience":"file-aud"}}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, cfg.EmbeddedIdP,
|
|
"Without an EmbeddedIdP object in the file the section stayed nil; environment variables were never read")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-11: optional pointer sections stayed nil unless present in the file.
|
|
func TestLoadManagementConfigDoesNotMaterializePointersFromEnvironment(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
value string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "missing http config",
|
|
environmentName: "NB_HTTPCONFIG_AUTHAUDIENCE",
|
|
value: "env-aud",
|
|
contents: `{}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.HttpConfig, "A missing HTTP section stayed nil; environment variables were never read")
|
|
},
|
|
},
|
|
{
|
|
name: "null http config",
|
|
environmentName: "NB_HTTPCONFIG_AUTHAUDIENCE",
|
|
value: "",
|
|
contents: `{"HttpConfig":null}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.HttpConfig, "A null HTTP section stayed nil; environment variables were never read")
|
|
},
|
|
},
|
|
{
|
|
name: "signal",
|
|
environmentName: "NB_SIGNAL_URI",
|
|
value: "sig:x",
|
|
contents: `{}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.Signal, "A missing signal section stayed nil; environment variables were never read")
|
|
},
|
|
},
|
|
{
|
|
name: "relay",
|
|
environmentName: "NB_RELAY_SECRET",
|
|
value: "s",
|
|
contents: `{}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.Relay, "A missing relay section stayed nil; environment variables were never read")
|
|
},
|
|
},
|
|
{
|
|
name: "embedded idp owner",
|
|
environmentName: "NB_EMBEDDEDIDP_OWNER_EMAIL",
|
|
value: "owner@example.com",
|
|
contents: `{"EmbeddedIdP":{"Enabled":true,"Issuer":"https://management.example.com/oauth2"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.EmbeddedIdP, "Embedded IdP configuration should be decoded")
|
|
assert.Nil(t, cfg.EmbeddedIdP.Owner, "A missing owner stayed nil; environment variables were never read")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-12: variables named after struct slices or maps were ignored instead of aborting startup.
|
|
func TestLoadManagementConfigIgnoresCollectionEnvironmentValues(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
value string
|
|
}{
|
|
{name: "stuns", environmentName: "NB_STUNS", value: "stun:stun.example.com:3478"},
|
|
{name: "stuns json", environmentName: "NB_STUNS", value: `[{"Proto":"udp","URI":"stun:x"}]`},
|
|
{name: "turns", environmentName: "NB_TURNCONFIG_TURNS", value: "turn:x"},
|
|
{name: "static connectors", environmentName: "NB_EMBEDDEDIDP_STATICCONNECTORS", value: "x"},
|
|
{name: "empty per account versions", environmentName: "NB_PERACCOUNTHIGHESTSUPPORTEDSYNCMESSAGEVERSION", value: ""},
|
|
{name: "per account versions", environmentName: "NB_PERACCOUNTHIGHESTSUPPORTEDSYNCMESSAGEVERSION", value: "acc1=1"},
|
|
{name: "empty extra config", environmentName: "NB_IDPMANAGERCONFIG_EXTRACONFIG", value: ""},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"Stuns": [{"Proto":"udp","URI":"stun:file.example.com:3478"}],
|
|
"TURNConfig": {"Turns": [{"Proto":"udp","URI":"turn:file.example.com:3478"}]},
|
|
"EmbeddedIdP": {"Enabled": true, "Issuer": "https://management.example.com/oauth2"},
|
|
"PerAccountHighestSupportedSyncMessageVersion": {"acc1": 1},
|
|
"IdpManagerConfig": {"ExtraConfig": {"CustomerId": "customer-id"}}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err, "Environment variables were never read, so a collection-named variable must not fail startup")
|
|
assert.Len(t, cfg.Stuns, 1, "File STUN servers should be retained")
|
|
require.NotNil(t, cfg.TURNConfig, "TURN configuration should be decoded")
|
|
assert.Len(t, cfg.TURNConfig.Turns, 1, "File TURN servers should be retained")
|
|
assert.Equal(t, map[string]int{"acc1": 1}, cfg.PerAccountHighestSupportedSyncMessageVersion,
|
|
"File per-account versions should be retained")
|
|
require.NotNil(t, cfg.IdpManagerConfig, "IDP configuration should be decoded")
|
|
assert.Equal(t, map[string]string{"CustomerId": "customer-id"}, map[string]string(cfg.IdpManagerConfig.ExtraConfig),
|
|
"File extra IDP configuration should be retained")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-13: list-valued environment variables did not exist; lists came only from JSON arrays.
|
|
func TestLoadManagementConfigPreservesLegacyFileListsOverEnvironment(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
value string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "relay addresses with spaces",
|
|
environmentName: "NB_RELAY_ADDRESSES",
|
|
value: "rel://a, rel://b",
|
|
contents: `{"Relay":{"Addresses":["rel://file"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Relay, "Relay configuration should be decoded")
|
|
assert.Equal(t, []string{"rel://file"}, cfg.Relay.Addresses,
|
|
"Relay addresses came only from the JSON array; environment lists were never parsed")
|
|
},
|
|
},
|
|
{
|
|
name: "relay addresses trailing comma",
|
|
environmentName: "NB_RELAY_ADDRESSES",
|
|
value: "rel://a,rel://b,",
|
|
contents: `{"Relay":{"Addresses":["rel://file"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Relay, "Relay configuration should be decoded")
|
|
assert.Equal(t, []string{"rel://file"}, cfg.Relay.Addresses,
|
|
"Relay addresses came only from the JSON array; environment lists were never parsed")
|
|
},
|
|
},
|
|
{
|
|
name: "relay addresses json",
|
|
environmentName: "NB_RELAY_ADDRESSES",
|
|
value: `["rel://a","rel://b"]`,
|
|
contents: `{"Relay":{"Addresses":["rel://file"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Relay, "Relay configuration should be decoded")
|
|
assert.Equal(t, []string{"rel://file"}, cfg.Relay.Addresses,
|
|
"Relay addresses came only from the JSON array; environment lists were never parsed")
|
|
},
|
|
},
|
|
{
|
|
name: "grant types",
|
|
environmentName: "NB_EMBEDDEDIDP_GRANTTYPES",
|
|
value: "authorization_code,refresh_token",
|
|
contents: `{"EmbeddedIdP":{"Enabled":true,"GrantTypes":["device_code"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.EmbeddedIdP, "Embedded IdP configuration should be decoded")
|
|
assert.Equal(t, []string{"device_code"}, cfg.EmbeddedIdP.GrantTypes,
|
|
"Grant types came only from the JSON array; environment lists were never parsed")
|
|
},
|
|
},
|
|
{
|
|
name: "trusted proxies with spaces",
|
|
environmentName: "NB_REVERSEPROXY_TRUSTEDHTTPPROXIES",
|
|
value: "10.0.0.0/8, 192.168.0.0/16",
|
|
contents: `{"ReverseProxy":{"TrustedHTTPProxies":["172.16.0.0/12"]}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.Len(t, cfg.ReverseProxy.TrustedHTTPProxies, 1,
|
|
"Trusted proxies came only from the JSON array; environment lists were never parsed")
|
|
assert.Equal(t, "172.16.0.0/12", cfg.ReverseProxy.TrustedHTTPProxies[0].String(),
|
|
"Trusted proxies came only from the JSON array; environment lists were never parsed")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err, "Environment lists were never parsed, so they must not fail startup")
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-14: boolean spellings in the environment were never interpreted.
|
|
func TestLoadManagementConfigIgnoresBooleanEnvironmentSpellings(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
contents string
|
|
expected bool
|
|
}{
|
|
{name: "one", value: "1", contents: `{"DisableDefaultPolicy":false}`, expected: false},
|
|
{name: "true", value: "TRUE", contents: `{"DisableDefaultPolicy":false}`, expected: false},
|
|
{name: "yes", value: "yes", contents: `{"DisableDefaultPolicy":false}`, expected: false},
|
|
{name: "on", value: "on", contents: `{"DisableDefaultPolicy":false}`, expected: false},
|
|
{name: "zero", value: "0", contents: `{"DisableDefaultPolicy":true}`, expected: true},
|
|
{name: "no", value: "no", contents: `{"DisableDefaultPolicy":true}`, expected: true},
|
|
{name: "off", value: "off", contents: `{"DisableDefaultPolicy":true}`, expected: true},
|
|
{name: "empty", value: "", contents: `{"DisableDefaultPolicy":true}`, expected: true},
|
|
{name: "invalid", value: "maybe", contents: `{"DisableDefaultPolicy":true}`, expected: true},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv("NB_DISABLEDEFAULTPOLICY", test.value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err, "Boolean environment spellings were never interpreted, so they must not fail startup")
|
|
assert.Equal(t, test.expected, cfg.DisableDefaultPolicy,
|
|
"NB_DISABLEDEFAULTPOLICY was never read; the file value must be used")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-15: an empty environment value never zeroed numeric fields.
|
|
func TestLoadManagementConfigPreservesLegacyNumericFileValuesOverEmptyEnvironment(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environmentName string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "missing sync version",
|
|
environmentName: "NB_HIGHESTSUPPORTEDSYNCMESSAGEVERSION",
|
|
contents: `{}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.HighestSupportedSyncMessageVersion,
|
|
"An empty NB_ variable was ignored, so a missing sync version must stay nil")
|
|
},
|
|
},
|
|
{
|
|
name: "configured sync version",
|
|
environmentName: "NB_HIGHESTSUPPORTEDSYNCMESSAGEVERSION",
|
|
contents: `{"HighestSupportedSyncMessageVersion":1}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HighestSupportedSyncMessageVersion, "Sync version should be decoded")
|
|
assert.Equal(t, 1, *cfg.HighestSupportedSyncMessageVersion,
|
|
"An empty NB_ variable was ignored, so the file sync version must be retained")
|
|
},
|
|
},
|
|
{
|
|
name: "trusted proxies count",
|
|
environmentName: "NB_REVERSEPROXY_TRUSTEDHTTPPROXIESCOUNT",
|
|
contents: `{"ReverseProxy":{"TrustedHTTPProxiesCount":2}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, uint(2), cfg.ReverseProxy.TrustedHTTPProxiesCount,
|
|
"An empty NB_ variable was ignored, so the file proxy count must be retained")
|
|
},
|
|
},
|
|
{
|
|
name: "access log retention",
|
|
environmentName: "NB_REVERSEPROXY_ACCESSLOGRETENTIONDAYS",
|
|
contents: `{"ReverseProxy":{"AccessLogRetentionDays":30}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, 30, cfg.ReverseProxy.AccessLogRetentionDays,
|
|
"An empty NB_ variable was ignored, so the file retention must be retained")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv(test.environmentName, "")
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-16: admin and legacy token commands operated strictly on the file's view of the deployment.
|
|
func TestLoadAdminMgmtConfigIgnoresEnvironment(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
environment map[string]string
|
|
contents string
|
|
applyIDPDefaults bool
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "store engine",
|
|
environment: map[string]string{"NB_STORECONFIG_ENGINE": "postgres"},
|
|
contents: `{"StoreConfig":{"Engine":"sqlite"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "sqlite", string(cfg.StoreConfig.Engine),
|
|
"Admin commands read the store engine from the file only")
|
|
},
|
|
},
|
|
{
|
|
name: "encryption key",
|
|
environment: map[string]string{"NB_DATASTOREENCRYPTIONKEY": "env-key"},
|
|
contents: `{"DataStoreEncryptionKey":"file-key"}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Equal(t, "file-key", cfg.DataStoreEncryptionKey,
|
|
"Admin commands read the encryption key from the file only")
|
|
},
|
|
},
|
|
{
|
|
name: "embedded idp enabled",
|
|
environment: map[string]string{"NB_EMBEDDEDIDP_ENABLED": "true"},
|
|
contents: `{"HttpConfig":{"AuthAudience":"file-aud"}}`,
|
|
applyIDPDefaults: true,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.Nil(t, cfg.EmbeddedIdP, "Admin commands never enabled the embedded IdP from the environment")
|
|
require.NotNil(t, cfg.HttpConfig, "HTTP configuration should be decoded")
|
|
assert.Equal(t, "file-aud", cfg.HttpConfig.AuthAudience,
|
|
"HTTP configuration must not be rewritten by an environment-enabled embedded IdP")
|
|
},
|
|
},
|
|
{
|
|
name: "invalid value",
|
|
environment: map[string]string{"NB_DISABLEDEFAULTPOLICY": "maybe"},
|
|
contents: `{"DisableDefaultPolicy":true}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
assert.True(t, cfg.DisableDefaultPolicy, "Admin commands read the policy flag from the file only")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
for name, value := range test.environment {
|
|
t.Setenv(name, value)
|
|
}
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
setAdminConfigPath(t, configPath)
|
|
|
|
cfg, _, err := loadAdminMgmtConfig(context.Background(), test.applyIDPDefaults)
|
|
require.NoError(t, err, "Admin commands never read the environment, so it must not fail loading")
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-17: the store engine in the config came from the file only; NETBIRD_STORE_ENGINE
|
|
// was consulted later by the store package.
|
|
func TestLoadManagementConfigIgnoresEnvironmentStoreEngine(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
t.Setenv("NETBIRD_STORE_ENGINE", "sqlite")
|
|
|
|
for _, value := range []string{"postgres", "Postgres"} {
|
|
t.Run(value, func(t *testing.T) {
|
|
t.Setenv("NB_STORECONFIG_ENGINE", value)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"StoreConfig":{"Engine":""}}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, string(cfg.StoreConfig.Engine),
|
|
"NB_STORECONFIG_ENGINE was never read; an empty file engine left NETBIRD_STORE_ENGINE in charge")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-20: the certificate override only fired when both flag values were non-empty.
|
|
func TestApplyCommandLineOverridesPreservesLegacyMixedCertificatePair(t *testing.T) {
|
|
oldCertFile := certFile
|
|
oldCertKey := certKey
|
|
t.Cleanup(func() {
|
|
certFile = oldCertFile
|
|
certKey = oldCertKey
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
certFile string
|
|
certKey string
|
|
}{
|
|
{name: "empty key", certFile: "/new.crt", certKey: ""},
|
|
{name: "empty file", certFile: "", certKey: "/new.key"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
certFile = test.certFile
|
|
certKey = test.certKey
|
|
flags := unchangedManagementFlags()
|
|
require.NoError(t, flags.Set("cert-file", test.certFile))
|
|
require.NoError(t, flags.Set("cert-key", test.certKey))
|
|
cfg := &nbconfig.Config{
|
|
HttpConfig: &nbconfig.HttpServerConfig{
|
|
CertFile: "/file/tls.crt",
|
|
CertKey: "/file/tls.key",
|
|
},
|
|
}
|
|
|
|
ApplyCommandLineOverrides(cfg, flags)
|
|
assert.Equal(t, "/file/tls.crt", cfg.HttpConfig.CertFile,
|
|
"A partially empty certificate pair never overrode the file certificate")
|
|
assert.Equal(t, "/file/tls.key", cfg.HttpConfig.CertKey,
|
|
"A partially empty certificate pair never overrode the file key")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-21: TLS flags with a missing HttpConfig section crashed instead of continuing.
|
|
func TestApplyCommandLineOverridesPreservesLegacyMissingHTTPConfigPanic(t *testing.T) {
|
|
oldLetsencryptDomain := mgmtLetsencryptDomain
|
|
oldCertFile := certFile
|
|
oldCertKey := certKey
|
|
t.Cleanup(func() {
|
|
mgmtLetsencryptDomain = oldLetsencryptDomain
|
|
certFile = oldCertFile
|
|
certKey = oldCertKey
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
configure func(*testing.T, *pflag.FlagSet)
|
|
}{
|
|
{
|
|
name: "letsencrypt domain",
|
|
configure: func(t *testing.T, flags *pflag.FlagSet) {
|
|
mgmtLetsencryptDomain = "example.com"
|
|
require.NoError(t, flags.Set("letsencrypt-domain", "example.com"))
|
|
},
|
|
},
|
|
{
|
|
name: "certificate pair",
|
|
configure: func(t *testing.T, flags *pflag.FlagSet) {
|
|
certFile = "/tls.crt"
|
|
certKey = "/tls.key"
|
|
require.NoError(t, flags.Set("cert-file", "/tls.crt"))
|
|
require.NoError(t, flags.Set("cert-key", "/tls.key"))
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
mgmtLetsencryptDomain = ""
|
|
certFile = ""
|
|
certKey = ""
|
|
flags := unchangedManagementFlags()
|
|
test.configure(t, flags)
|
|
cfg := &nbconfig.Config{Datadir: "/d", DataStoreEncryptionKey: "k"}
|
|
|
|
assert.Panics(t, func() { ApplyCommandLineOverrides(cfg, flags) },
|
|
"TLS flags with no HttpConfig section historically dereferenced a nil pointer and crashed startup")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-23: the config file was always parsed as JSON regardless of its extension.
|
|
func TestLoadManagementConfigParsesJSONRegardlessOfExtension(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
for _, extension := range []string{".toml", ".ini", ".env", ".dotenv", ".properties", ".props", ".prop", ".hcl", ".tfvars"} {
|
|
t.Run(extension, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management"+extension)
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"Datadir":"/file-data"}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err, "JSON content was always accepted regardless of the file extension")
|
|
assert.Equal(t, "/file-data", cfg.Datadir, "JSON content should be decoded regardless of the file extension")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-24: account IDs used as map keys kept their case.
|
|
func TestLoadManagementConfigPreservesPerAccountKeyCase(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"PerAccountHighestSupportedSyncMessageVersion":{"AbC123":1}}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]int{"AbC123": 1}, cfg.PerAccountHighestSupportedSyncMessageVersion,
|
|
"Per-account sync version keys are account IDs and must retain their case")
|
|
}
|
|
|
|
// management-25: connector configuration keys inside slices kept their case.
|
|
func TestLoadManagementConfigPreservesStaticConnectorConfigKeyCase(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"EmbeddedIdP": {
|
|
"Enabled": true,
|
|
"StaticConnectors": [
|
|
{"type": "oidc", "id": "x", "config": {"clientID": "a", "redirectURI": "b", "insecureEnableGroups": false}}
|
|
]
|
|
}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg.EmbeddedIdP, "Embedded IdP configuration should be decoded")
|
|
require.Len(t, cfg.EmbeddedIdP.StaticConnectors, 1, "Static connectors should be decoded")
|
|
assert.Equal(t, map[string]any{
|
|
"clientID": "a",
|
|
"redirectURI": "b",
|
|
"insecureEnableGroups": false,
|
|
}, cfg.EmbeddedIdP.StaticConnectors[0].Config, "Dex connector option keys are case-sensitive and must retain their case")
|
|
}
|
|
|
|
// management-26: mistyped scalars in the file were rejected instead of coerced.
|
|
func TestLoadManagementConfigRejectsWeaklyTypedFileValues(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
}{
|
|
{name: "string sync version", contents: `{"HighestSupportedSyncMessageVersion":"1"}`},
|
|
{name: "string per account version", contents: `{"PerAccountHighestSupportedSyncMessageVersion":{"a":"1"}}`},
|
|
{name: "numeric datadir", contents: `{"Datadir":123}`},
|
|
{name: "numeric engine", contents: `{"StoreConfig":{"Engine":1}}`},
|
|
{name: "numeric proto", contents: `{"Signal":{"Proto":1}}`},
|
|
{name: "string bool", contents: `{"DisableDefaultPolicy":"true"}`},
|
|
{name: "upper string bool", contents: `{"DisableDefaultPolicy":"TRUE"}`},
|
|
{name: "yes bool", contents: `{"DisableDefaultPolicy":"yes"}`},
|
|
{name: "on bool", contents: `{"DisableDefaultPolicy":"on"}`},
|
|
{name: "numeric bool", contents: `{"DisableDefaultPolicy":1}`},
|
|
{name: "string login flag", contents: `{"PKCEAuthorizationFlow":{"ProviderConfig":{"LoginFlag":"1"}}}`},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
_, err := loadManagementConfig(configPath)
|
|
assert.Error(t, err, "encoding/json rejected mistyped scalars, so loading must fail instead of coercing the value")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-27: numeric edge cases were rejected or decoded exactly rather than silently mangled.
|
|
func TestLoadManagementConfigPreservesLegacyNumericDecoding(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config, error)
|
|
}{
|
|
{
|
|
name: "fractional sync version",
|
|
contents: `{"HighestSupportedSyncMessageVersion":1.7}`,
|
|
validate: func(t *testing.T, _ *nbconfig.Config, err error) {
|
|
assert.Error(t, err, "encoding/json rejected a fractional number for an int field")
|
|
},
|
|
},
|
|
{
|
|
name: "whole float sync version",
|
|
contents: `{"HighestSupportedSyncMessageVersion":1.0}`,
|
|
validate: func(t *testing.T, _ *nbconfig.Config, err error) {
|
|
assert.Error(t, err, "encoding/json rejected a float literal for an int field")
|
|
},
|
|
},
|
|
{
|
|
name: "fractional retention days",
|
|
contents: `{"ReverseProxy":{"AccessLogRetentionDays":7.9}}`,
|
|
validate: func(t *testing.T, _ *nbconfig.Config, err error) {
|
|
assert.Error(t, err, "encoding/json rejected a fractional number for an int field")
|
|
},
|
|
},
|
|
{
|
|
name: "negative proxy count",
|
|
contents: `{"ReverseProxy":{"TrustedHTTPProxiesCount":-1}}`,
|
|
validate: func(t *testing.T, _ *nbconfig.Config, err error) {
|
|
assert.Error(t, err, "encoding/json rejected a negative number for a uint field")
|
|
},
|
|
},
|
|
{
|
|
name: "overflowing login flag",
|
|
contents: `{"PKCEAuthorizationFlow":{"ProviderConfig":{"LoginFlag":300}}}`,
|
|
validate: func(t *testing.T, _ *nbconfig.Config, err error) {
|
|
assert.Error(t, err, "encoding/json rejected a number overflowing a uint8 field")
|
|
},
|
|
},
|
|
{
|
|
name: "large sync version",
|
|
contents: `{"HighestSupportedSyncMessageVersion":9007199254740993}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg.HighestSupportedSyncMessageVersion, "Sync version should be decoded")
|
|
assert.Equal(t, 9007199254740993, *cfg.HighestSupportedSyncMessageVersion,
|
|
"encoding/json decoded large integers exactly without float64 precision loss")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
test.validate(t, cfg, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-28: the rewritten config reproduced the decoded struct shape.
|
|
func TestEnsureEncryptionKeyPersistsLegacyConfigShape(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{
|
|
"HttpConfig": {},
|
|
"Relay": {},
|
|
"IdpManagerConfig": {
|
|
"ExtraConfig": {
|
|
"ServiceAccountKey": "service-account-key"
|
|
}
|
|
},
|
|
"PerAccountHighestSupportedSyncMessageVersion": {"AbC123": 1}
|
|
}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, EnsureEncryptionKey(context.Background(), configPath, cfg))
|
|
|
|
persistedConfig, err := os.ReadFile(configPath)
|
|
require.NoError(t, err)
|
|
var persisted map[string]any
|
|
require.NoError(t, json.Unmarshal(persistedConfig, &persisted))
|
|
|
|
_, httpConfigIsObject := persisted["HttpConfig"].(map[string]any)
|
|
assert.True(t, httpConfigIsObject, "An explicitly configured empty HttpConfig was persisted as an object, not null")
|
|
_, relayIsObject := persisted["Relay"].(map[string]any)
|
|
assert.True(t, relayIsObject, "An explicitly configured empty Relay section was persisted as an object, not null")
|
|
|
|
idpConfig, _ := persisted["IdpManagerConfig"].(map[string]any)
|
|
extraConfig, _ := idpConfig["ExtraConfig"].(map[string]any)
|
|
assert.Contains(t, extraConfig, "ServiceAccountKey", "Persisted IDP-specific keys retained their case")
|
|
|
|
perAccount, _ := persisted["PerAccountHighestSupportedSyncMessageVersion"].(map[string]any)
|
|
assert.Contains(t, perAccount, "AbC123", "Persisted per-account keys retained their case")
|
|
}
|
|
|
|
// management-29: an empty config file failed to load instead of silently producing defaults.
|
|
func TestLoadManagementConfigRejectsEmptyFile(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, nil, 0o600))
|
|
|
|
_, err := loadManagementConfig(configPath)
|
|
assert.Error(t, err, "json.Unmarshal rejected a zero-byte file with 'unexpected end of JSON input'")
|
|
}
|
|
|
|
// management-30: duplicate object keys differing only in case were merged into the same struct.
|
|
func TestLoadManagementConfigMergesCaseVariantDuplicateObjects(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(`{"Signal":{"URI":"signal.example.com:10000"},"signal":{"Proto":"udp"}}`), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg.Signal, "Signal configuration should be decoded")
|
|
assert.Equal(t, "signal.example.com:10000", cfg.Signal.URI,
|
|
"encoding/json decoded case-variant duplicate objects into the same struct, keeping both fields")
|
|
assert.Equal(t, nbconfig.UDP, cfg.Signal.Proto,
|
|
"encoding/json decoded case-variant duplicate objects into the same struct, keeping both fields")
|
|
}
|
|
|
|
// management-31: a scalar string for a slice field in the file was rejected.
|
|
func TestLoadManagementConfigRejectsScalarStringsForSlices(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
}{
|
|
{name: "relay addresses", contents: `{"Relay":{"Addresses":"rel://a,rel://b"}}`},
|
|
{name: "grant types", contents: `{"EmbeddedIdP":{"GrantTypes":"authorization_code"}}`},
|
|
{name: "trusted proxies", contents: `{"ReverseProxy":{"TrustedHTTPProxies":"10.0.0.0/8,192.168.0.0/16"}}`},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
_, err := loadManagementConfig(configPath)
|
|
assert.Error(t, err, "encoding/json rejected a string for a slice field instead of splitting it on commas")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-32: a null duration in the file was rejected.
|
|
func TestLoadManagementConfigRejectsNullDurations(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
}{
|
|
{name: "turn credentials ttl", contents: `{"TURNConfig":{"CredentialsTTL":null}}`},
|
|
{name: "relay credentials ttl", contents: `{"Relay":{"CredentialsTTL":null}}`},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
_, err := loadManagementConfig(configPath)
|
|
assert.Error(t, err, "util.Duration rejected JSON null with 'invalid duration' instead of defaulting to 0s")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-critic-1: duplicate same-case object keys were decoded into the same struct, merging both blocks.
|
|
func TestLoadManagementConfigMergesSameCaseDuplicateObjects(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
validate func(*testing.T, *nbconfig.Config)
|
|
}{
|
|
{
|
|
name: "signal",
|
|
contents: `{"Signal":{"URI":"signal.example.com:10000"},"Signal":{"Proto":"udp"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.Signal, "Signal configuration should be decoded")
|
|
assert.Equal(t, "signal.example.com:10000", cfg.Signal.URI,
|
|
"encoding/json decoded duplicate Signal objects into the same struct, keeping the first block's fields")
|
|
assert.Equal(t, nbconfig.UDP, cfg.Signal.Proto,
|
|
"encoding/json decoded duplicate Signal objects into the same struct, keeping the second block's fields")
|
|
},
|
|
},
|
|
{
|
|
name: "http config",
|
|
contents: `{"HttpConfig":{"AuthAudience":"a"},"HttpConfig":{"AuthIssuer":"i"}}`,
|
|
validate: func(t *testing.T, cfg *nbconfig.Config) {
|
|
require.NotNil(t, cfg.HttpConfig, "HTTP configuration should be decoded")
|
|
assert.Equal(t, "a", cfg.HttpConfig.AuthAudience,
|
|
"encoding/json decoded duplicate HttpConfig objects into the same struct, keeping the first block's fields")
|
|
assert.Equal(t, "i", cfg.HttpConfig.AuthIssuer,
|
|
"encoding/json decoded duplicate HttpConfig objects into the same struct, keeping the second block's fields")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
test.validate(t, cfg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-critic-2: case-variant duplicate scalar keys were applied in document order, so the last one won.
|
|
func TestLoadManagementConfigPreservesLegacyDocumentOrderForCaseVariantScalars(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "uppercase first",
|
|
contents: `{"Datadir":"/first-data","datadir":"/second-data"}`,
|
|
expected: "/second-data",
|
|
},
|
|
{
|
|
name: "lowercase first",
|
|
contents: `{"datadir":"/first-data","Datadir":"/second-data"}`,
|
|
expected: "/second-data",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.expected, cfg.Datadir,
|
|
"encoding/json applied case-variant duplicate scalar keys in document order, so the last key in the file won")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-critic-3: NB_DATASTOREENCRYPTIONKEY was never read, so a file without a key always got a generated one persisted.
|
|
func TestEnsureEncryptionKeyIgnoresEnvironmentKeyWhenFileHasNone(t *testing.T) {
|
|
const environmentKey = "environment-key"
|
|
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
}{
|
|
{name: "missing key", contents: `{"HttpConfig":{"AuthAudience":"file-aud"}}`},
|
|
{name: "empty key", contents: `{"DataStoreEncryptionKey":"","HttpConfig":{"AuthAudience":"file-aud"}}`},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Setenv("NB_DATASTOREENCRYPTIONKEY", environmentKey)
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
cfg, err := loadManagementConfig(configPath)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cfg.DataStoreEncryptionKey,
|
|
"NB_DATASTOREENCRYPTIONKEY was never read; a file without a key loaded with an empty key")
|
|
|
|
require.NoError(t, EnsureEncryptionKey(context.Background(), configPath, cfg))
|
|
assert.NotEmpty(t, cfg.DataStoreEncryptionKey,
|
|
"EnsureEncryptionKey generated a fresh key when the file had none")
|
|
assert.NotEqual(t, environmentKey, cfg.DataStoreEncryptionKey,
|
|
"EnsureEncryptionKey generated a random key instead of adopting the environment value")
|
|
|
|
persistedConfig, err := os.ReadFile(configPath)
|
|
require.NoError(t, err)
|
|
var persisted map[string]any
|
|
require.NoError(t, json.Unmarshal(persistedConfig, &persisted))
|
|
persistedKey, _ := persisted["DataStoreEncryptionKey"].(string)
|
|
assert.NotEmpty(t, persistedKey,
|
|
"The generated key was written back to management.json so later starts reuse it")
|
|
assert.NotEqual(t, environmentKey, persistedKey,
|
|
"The persisted key was the generated one, never the environment value")
|
|
})
|
|
}
|
|
}
|
|
|
|
// management-critic-4: a single JSON object where an array of hosts was expected was rejected.
|
|
func TestLoadManagementConfigRejectsObjectsForHostSlices(t *testing.T) {
|
|
clearManagementConfigEnvironment(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
}{
|
|
{name: "stuns", contents: `{"Stuns":{"Proto":"udp","URI":"stun:x"}}`},
|
|
{name: "turns", contents: `{"TURNConfig":{"Turns":{"Proto":"udp","URI":"turn:x"}}}`},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
require.NoError(t, os.WriteFile(configPath, []byte(test.contents), 0o600))
|
|
|
|
_, err := loadManagementConfig(configPath)
|
|
assert.Error(t, err,
|
|
"encoding/json rejected an object for a []*Host field ('cannot unmarshal object into Go struct field') instead of wrapping it into a one-element slice")
|
|
})
|
|
}
|
|
}
|
|
|
|
func setAdminConfigPath(t *testing.T, configPath string) {
|
|
t.Helper()
|
|
|
|
oldConfigPath := nbconfig.MgmtConfigPath
|
|
oldAdminDatadir := adminDatadir
|
|
nbconfig.MgmtConfigPath = configPath
|
|
adminDatadir = ""
|
|
t.Cleanup(func() {
|
|
nbconfig.MgmtConfigPath = oldConfigPath
|
|
adminDatadir = oldAdminDatadir
|
|
})
|
|
}
|
|
|
|
func clearManagementConfigEnvironment(t *testing.T) {
|
|
t.Helper()
|
|
clearManagementEnvironmentType(t, reflect.TypeOf(nbconfig.Config{}), "", make(map[reflect.Type]bool))
|
|
}
|
|
|
|
func clearManagementEnvironmentType(t *testing.T, configType reflect.Type, prefix string, visiting map[reflect.Type]bool) {
|
|
t.Helper()
|
|
|
|
for configType.Kind() == reflect.Pointer {
|
|
configType = configType.Elem()
|
|
}
|
|
if configType.Kind() != reflect.Struct || visiting[configType] {
|
|
return
|
|
}
|
|
visiting[configType] = true
|
|
defer delete(visiting, configType)
|
|
|
|
for i := range configType.NumField() {
|
|
field := configType.Field(i)
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
key := strings.Split(field.Tag.Get("json"), ",")[0]
|
|
if key == "-" {
|
|
continue
|
|
}
|
|
if key == "" {
|
|
key = field.Name
|
|
}
|
|
if prefix != "" {
|
|
key = prefix + "." + key
|
|
}
|
|
environmentName := "NB_" + strings.ToUpper(strings.NewReplacer(".", "_", "-", "_").Replace(key))
|
|
t.Setenv(environmentName, "")
|
|
require.NoError(t, os.Unsetenv(environmentName))
|
|
clearManagementEnvironmentType(t, field.Type, key, visiting)
|
|
}
|
|
}
|
|
|
|
func runManagementPreRun(t *testing.T, httpConfig string, changedFlags map[string]string) int {
|
|
t.Helper()
|
|
return runManagementPreRunWithEnvironment(t, httpConfig, changedFlags, nil)
|
|
}
|
|
|
|
func runManagementPreRunWithEnvironment(t *testing.T, httpConfig string, changedFlags map[string]string, environment map[string]string) int {
|
|
t.Helper()
|
|
|
|
for _, name := range []string{
|
|
"NB_DATADIR",
|
|
"NB_DATASTOREENCRYPTIONKEY",
|
|
"NB_HTTPCONFIG_LETSENCRYPTDOMAIN",
|
|
"NB_HTTPCONFIG_CERTFILE",
|
|
"NB_HTTPCONFIG_CERTKEY",
|
|
} {
|
|
t.Setenv(name, "")
|
|
require.NoError(t, os.Unsetenv(name))
|
|
}
|
|
for name, value := range environment {
|
|
t.Setenv(name, value)
|
|
}
|
|
|
|
configPath := filepath.Join(t.TempDir(), "management.json")
|
|
contents := `{"DataStoreEncryptionKey":"configured-key","HttpConfig":` + httpConfig + `}`
|
|
require.NoError(t, os.WriteFile(configPath, []byte(contents), 0o600))
|
|
|
|
flags := map[string]*pflag.Flag{
|
|
"port": mgmtCmd.Flags().Lookup("port"),
|
|
"datadir": mgmtCmd.Flags().Lookup("datadir"),
|
|
"letsencrypt-domain": mgmtCmd.Flags().Lookup("letsencrypt-domain"),
|
|
"cert-file": mgmtCmd.Flags().Lookup("cert-file"),
|
|
"cert-key": mgmtCmd.Flags().Lookup("cert-key"),
|
|
}
|
|
for name, flag := range flags {
|
|
require.NotNil(t, flag, "Management compatibility flag %s should be registered", name)
|
|
}
|
|
|
|
oldConfigPath := nbconfig.MgmtConfigPath
|
|
oldCommandContext := mgmtCmd.Context()
|
|
oldMgmtPort := mgmtPort
|
|
oldMgmtDataDir := mgmtDataDir
|
|
oldLogLevel := logLevel
|
|
oldLogFile := logFile
|
|
oldDNSDomain := dnsDomain
|
|
oldLetsencryptDomain := mgmtLetsencryptDomain
|
|
oldCertFile := certFile
|
|
oldCertKey := certKey
|
|
oldConfig := config
|
|
oldFlagStates := make(map[*pflag.Flag]struct {
|
|
value string
|
|
changed bool
|
|
}, len(flags))
|
|
for _, flag := range flags {
|
|
oldFlagStates[flag] = struct {
|
|
value string
|
|
changed bool
|
|
}{value: flag.Value.String(), changed: flag.Changed}
|
|
}
|
|
t.Cleanup(func() {
|
|
for flag, state := range oldFlagStates {
|
|
require.NoError(t, flag.Value.Set(state.value))
|
|
flag.Changed = state.changed
|
|
}
|
|
nbconfig.MgmtConfigPath = oldConfigPath
|
|
mgmtCmd.SetContext(oldCommandContext)
|
|
mgmtPort = oldMgmtPort
|
|
mgmtDataDir = oldMgmtDataDir
|
|
logLevel = oldLogLevel
|
|
logFile = oldLogFile
|
|
dnsDomain = oldDNSDomain
|
|
mgmtLetsencryptDomain = oldLetsencryptDomain
|
|
certFile = oldCertFile
|
|
certKey = oldCertKey
|
|
config = oldConfig
|
|
})
|
|
|
|
baseline := map[string]string{
|
|
"port": "80",
|
|
"datadir": defaultMgmtDataDir,
|
|
"letsencrypt-domain": "",
|
|
"cert-file": "",
|
|
"cert-key": "",
|
|
}
|
|
for name, value := range baseline {
|
|
require.NoError(t, flags[name].Value.Set(value))
|
|
flags[name].Changed = false
|
|
}
|
|
for name, value := range changedFlags {
|
|
flag, ok := flags[name]
|
|
require.True(t, ok, "Compatibility scenario should reference a known flag")
|
|
require.NoError(t, flag.Value.Set(value))
|
|
flag.Changed = true
|
|
}
|
|
|
|
nbconfig.MgmtConfigPath = configPath
|
|
mgmtCmd.SetContext(context.Background())
|
|
logLevel = "info"
|
|
logFile = "console"
|
|
dnsDomain = defaultSingleAccModeDomain
|
|
require.NoError(t, mgmtCmd.PreRunE(mgmtCmd, nil))
|
|
return mgmtPort
|
|
}
|
|
|
|
func unchangedManagementFlags() *pflag.FlagSet {
|
|
flags := pflag.NewFlagSet("management", pflag.ContinueOnError)
|
|
flags.String("datadir", defaultMgmtDataDir, "")
|
|
flags.String("letsencrypt-domain", "", "")
|
|
flags.String("cert-key", "", "")
|
|
flags.String("cert-file", "", "")
|
|
return flags
|
|
}
|