It's a variation of the ADR described here: #310. Namespaces allow peers to publish files under paths like ``<namespace>/<pubkey>` if they hold a certificate signed by the network key. This enables dynamic, per-peer file publishing without requiring each file to be pre-configured. Also cleans up some testing patterns.
1344 lines
32 KiB
Go
1344 lines
32 KiB
Go
package config_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.clan.lol/clan/data-mesher/pkg/config"
|
|
"git.clan.lol/clan/data-mesher/pkg/crypto"
|
|
"git.clan.lol/clan/data-mesher/test"
|
|
"github.com/libp2p/go-libp2p/core/peer"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// clusterKeyPaths holds all file paths needed for a valid cluster TOML config.
|
|
type clusterKeyPaths struct {
|
|
IdentityKeyPath string
|
|
CertPath string
|
|
BootstrapPeerAddr string
|
|
}
|
|
|
|
// generateClusterKeys generates an identity key, a certificate signed by the
|
|
// given network key, and a bootstrap peer for use in TOML configs.
|
|
func generateClusterKeys(t *testing.T, networkKey *crypto.PrivateKey) clusterKeyPaths {
|
|
t.Helper()
|
|
|
|
as := require.New(t)
|
|
key, keyPath := test.GenerateIdentityKey(t)
|
|
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate(
|
|
"test-machine", key.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
|
|
)
|
|
as.NoError(err)
|
|
|
|
dir := t.TempDir()
|
|
certPath := filepath.Join(dir, "identity.cert")
|
|
as.NoError(crypto.WriteCertificate(cert, certPath))
|
|
|
|
_, bpAddr := test.GenerateBootstrapPeer(t)
|
|
|
|
return clusterKeyPaths{
|
|
IdentityKeyPath: keyPath,
|
|
CertPath: certPath,
|
|
BootstrapPeerAddr: bpAddr,
|
|
}
|
|
}
|
|
|
|
func validHTTP() config.HTTP {
|
|
return config.HTTP{
|
|
Port: 7331,
|
|
Interfaces: []string{"lo"},
|
|
}
|
|
}
|
|
|
|
func validCluster(t *testing.T, networkKey *crypto.PrivateKey) config.Cluster {
|
|
t.Helper()
|
|
|
|
as := require.New(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate(
|
|
"test-machine", key.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
|
|
)
|
|
as.NoError(err)
|
|
|
|
return config.Cluster{
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
Port: 7946,
|
|
PushPullInterval: 30 * time.Second,
|
|
IdentityKey: key,
|
|
IdentityCert: cert,
|
|
}
|
|
}
|
|
|
|
func validNetwork(t *testing.T, id *crypto.PublicKey) config.Network {
|
|
t.Helper()
|
|
|
|
return config.Network{
|
|
ID: id,
|
|
Files: validFiles(t),
|
|
}
|
|
}
|
|
|
|
func validFiles(t *testing.T) config.Files {
|
|
t.Helper()
|
|
|
|
return config.Files{
|
|
"test_file": nil,
|
|
}
|
|
}
|
|
|
|
func TestConfig_Validate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 2)
|
|
|
|
t.Run("valid config", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("valid config with explicit file signer", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{
|
|
"test_file": {keys[1].Public},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("missing state directory", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "state directory")
|
|
})
|
|
|
|
t.Run("missing files", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: config.Network{
|
|
ID: keys[0].Public,
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "at least one file should be configured")
|
|
})
|
|
|
|
t.Run("nil files", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: config.Network{
|
|
ID: keys[0].Public,
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "at least one file should be configured")
|
|
})
|
|
|
|
t.Run("max_file_size defaults when not set", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
MaxFileSize: 0, // not set
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(config.DefaultMaxFileSize), cfg.MaxFileSize)
|
|
})
|
|
|
|
t.Run("max_file_size custom value", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
MaxFileSize: 1024 * 1024, // 1 MiB
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1024*1024), cfg.MaxFileSize)
|
|
})
|
|
|
|
t.Run("max_file_size negative rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
MaxFileSize: -1,
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "max_file_size must be positive")
|
|
})
|
|
|
|
t.Run("valid file names", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
validNames := []string{
|
|
"test",
|
|
"test_file",
|
|
"test123",
|
|
"123",
|
|
"a",
|
|
"test_file_123",
|
|
"test/file",
|
|
"foo/bar",
|
|
"nested/path/name",
|
|
strings.Repeat("a", 255), // max length single segment
|
|
strings.Repeat("a", 255) + "/" + strings.Repeat("b", 255), // max length multi-segment
|
|
}
|
|
|
|
for _, name := range validNames {
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{
|
|
name: nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err, "expected %q to be a valid file name", name)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid file names", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
invalidNames := []string{
|
|
"", // empty
|
|
"Test", // uppercase
|
|
"TEST", // all uppercase
|
|
"test-file", // hyphen
|
|
"test.txt", // dot
|
|
"test file", // space
|
|
"test@file", // special char
|
|
"dns:foo", // colon not allowed
|
|
"/leading_slash", // leading slash not allowed
|
|
"trailing/", // trailing slash not allowed
|
|
"double//slash", // empty segments not allowed
|
|
strings.Repeat("a", 256), // segment too long
|
|
}
|
|
|
|
for _, name := range invalidNames {
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{
|
|
name: nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig, "expected %q to be an invalid file name", name)
|
|
require.ErrorContains(t, err, "file names must match", "expected %q to be an invalid file name", name)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHTTP_Validate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("valid config", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
http := config.HTTP{
|
|
Port: 7331,
|
|
Interfaces: []string{"lo"},
|
|
}
|
|
|
|
err := http.Validate()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"[::1]:7331", "127.0.0.1:7331"}, http.ListenAddresses)
|
|
})
|
|
|
|
t.Run("missing port", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
http := config.HTTP{
|
|
Port: 0,
|
|
Interfaces: []string{"lo"},
|
|
}
|
|
|
|
err := http.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "http port must be between 1 and 65535")
|
|
})
|
|
|
|
t.Run("missing interface", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
http := config.HTTP{
|
|
Port: 7331,
|
|
Interfaces: []string{},
|
|
}
|
|
|
|
err := http.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "at least one http interface is required")
|
|
})
|
|
|
|
t.Run("invalid interface", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
http := config.HTTP{
|
|
Port: 7331,
|
|
Interfaces: []string{"nonexistent-interface-12345"},
|
|
}
|
|
|
|
err := http.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "failed to resolve ip addresses for interface")
|
|
})
|
|
}
|
|
|
|
func TestCluster_Validate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
networkKey := test.GenerateKeys(t, 1)[0]
|
|
|
|
t.Run("valid config", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cluster := validCluster(t, networkKey)
|
|
|
|
err := cluster.Validate(networkKey.Public)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("missing bootstrap peers", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cluster := config.Cluster{
|
|
Port: 7946,
|
|
PushPullInterval: 30 * time.Second,
|
|
}
|
|
|
|
err := cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "at least one bootstrap peer is required")
|
|
})
|
|
|
|
t.Run("missing identity key", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
cluster := config.Cluster{
|
|
Port: 7946,
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
PushPullInterval: 30 * time.Second,
|
|
}
|
|
|
|
err := cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "identity key is required")
|
|
})
|
|
|
|
t.Run("push pull interval too short", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
cluster := config.Cluster{
|
|
Port: 7946,
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
IdentityKey: key,
|
|
PushPullInterval: 1 * time.Second,
|
|
}
|
|
|
|
err := cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "cluster push pull interval must be >= 5s")
|
|
})
|
|
|
|
t.Run("missing port", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
cluster := config.Cluster{
|
|
Port: 0,
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
IdentityKey: key,
|
|
PushPullInterval: 30 * time.Second,
|
|
}
|
|
|
|
err := cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "cluster port must be between 1 and 65535")
|
|
})
|
|
|
|
t.Run("push pull interval at minimum", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate(
|
|
"test-machine", key.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
|
|
)
|
|
as.NoError(err)
|
|
|
|
cluster := config.Cluster{
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
IdentityKey: key,
|
|
IdentityCert: cert,
|
|
Port: 7946,
|
|
PushPullInterval: config.MinPushPullInterval,
|
|
}
|
|
|
|
err = cluster.Validate(networkKey.Public)
|
|
as.NoError(err)
|
|
})
|
|
|
|
t.Run("missing identity cert", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
|
|
cluster := config.Cluster{
|
|
Port: 7946,
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
IdentityKey: key,
|
|
PushPullInterval: 30 * time.Second,
|
|
}
|
|
|
|
err := cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "identity certificate is required")
|
|
})
|
|
|
|
t.Run("cert key mismatch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
otherKey, _ := test.GenerateIdentityKey(t)
|
|
|
|
// Sign cert for otherKey's public key, not for key's
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate(
|
|
"test-machine", otherKey.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
|
|
)
|
|
as.NoError(err)
|
|
|
|
cluster := config.Cluster{
|
|
Port: 7946,
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
IdentityKey: key,
|
|
IdentityCert: cert,
|
|
PushPullInterval: 30 * time.Second,
|
|
}
|
|
|
|
err = cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "does not match identity key")
|
|
})
|
|
|
|
t.Run("cert not signed by network", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
bp, _ := test.GenerateBootstrapPeer(t)
|
|
key, _ := test.GenerateIdentityKey(t)
|
|
|
|
// Sign cert with a different key than the network key
|
|
wrongSigner := test.GenerateKeys(t, 1)[0]
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate(
|
|
"test-machine", key.Public, wrongSigner, now.Add(-time.Minute), now.Add(365*24*time.Hour),
|
|
)
|
|
as.NoError(err)
|
|
|
|
cluster := config.Cluster{
|
|
Port: 7946,
|
|
BootstrapPeers: []peer.AddrInfo{bp},
|
|
IdentityKey: key,
|
|
IdentityCert: cert,
|
|
PushPullInterval: 30 * time.Second,
|
|
}
|
|
|
|
err = cluster.Validate(networkKey.Public)
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "not signed by network certificate authority")
|
|
})
|
|
}
|
|
|
|
func readConfigFromTempFile(t *testing.T, toml string) *config.Config {
|
|
t.Helper()
|
|
|
|
as := require.New(t)
|
|
|
|
// Create a new viper instance
|
|
v, err := config.NewViper()
|
|
as.NoError(err)
|
|
|
|
// force viper to read the config from our temp file instead of the usual locations
|
|
as.NoError(v.ReadConfig(strings.NewReader(toml)), "failed to read config")
|
|
|
|
// Unmarshal viper config into a Config object
|
|
cfg, err := config.FromViper(v)
|
|
as.NoError(err)
|
|
|
|
return cfg
|
|
}
|
|
|
|
func TestFromViper_TOML(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 2)
|
|
|
|
t.Run("parse public key from file path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
// Write key to a temp file
|
|
keyFilePath := t.TempDir() + "/pubkey.pem"
|
|
as.NoError(keys[0].Public.WriteToFile(keyFilePath))
|
|
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_zone" = ["` + keyFilePath + `"]
|
|
`
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Len(cfg.Network.Files, 1)
|
|
as.Len(cfg.Network.Files["dns_zone"], 1)
|
|
as.Equal(keys[0].Public.String(), cfg.Network.Files["dns_zone"][0].String())
|
|
})
|
|
|
|
t.Run("parse mixed file paths and inline keys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
// Write one key to a file, use the other inline
|
|
keyFilePath := t.TempDir() + "/pubkey.pem"
|
|
as.NoError(keys[0].Public.WriteToFile(keyFilePath))
|
|
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_zone" = ["` + keyFilePath + `", "` + keys[1].Public.String() + `"]
|
|
`
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Len(cfg.Network.Files, 1)
|
|
as.Len(cfg.Network.Files["dns_zone"], 2)
|
|
as.Equal(keys[0].Public.String(), cfg.Network.Files["dns_zone"][0].String())
|
|
as.Equal(keys[1].Public.String(), cfg.Network.Files["dns_zone"][1].String())
|
|
})
|
|
|
|
t.Run("parse complete toml config", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
// Note: viper treats dots in map keys as path separators, so we use
|
|
// colon-style names like "dns_zone" instead of "file.txt" to avoid issues
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_zone1" = ["` + keys[0].Public.String() + `"]
|
|
"dns_zone2" = ["` + keys[0].Public.String() + `", "` + keys[1].Public.String() + `"]
|
|
`
|
|
as := require.New(t)
|
|
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
// Check the config values
|
|
as.Equal("/var/lib/data-mesher", cfg.StateDirectory)
|
|
|
|
as.Equal(uint16(8080), cfg.HTTP.Port)
|
|
as.Equal([]string{"lo"}, cfg.HTTP.Interfaces)
|
|
|
|
as.Equal(uint16(7946), cfg.Cluster.Port)
|
|
as.Equal([]string{"lo"}, cfg.Cluster.Interfaces)
|
|
as.Equal(15*time.Second, cfg.Cluster.PushPullInterval)
|
|
|
|
as.Len(cfg.Network.Files, 2)
|
|
as.Len(cfg.Network.Files["dns_zone1"], 1)
|
|
as.Equal(keys[0].Public.String(), cfg.Network.Files["dns_zone1"][0].String())
|
|
as.Len(cfg.Network.Files["dns_zone2"], 2)
|
|
})
|
|
|
|
t.Run("parse duration formats", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "1h"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_test" = ["` + keys[0].Public.String() + `"]
|
|
`
|
|
as := require.New(t)
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Equal(1*time.Hour, cfg.Cluster.PushPullInterval)
|
|
})
|
|
|
|
t.Run("parse max_file_size", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
max_file_size = 5242880
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_test" = ["` + keys[0].Public.String() + `"]
|
|
`
|
|
as := require.New(t)
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Equal(int64(5242880), cfg.MaxFileSize) // 5 MiB
|
|
})
|
|
|
|
t.Run("max_file_size defaults in toml", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_test" = ["` + keys[0].Public.String() + `"]
|
|
`
|
|
as := require.New(t)
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Equal(int64(config.DefaultMaxFileSize), cfg.MaxFileSize)
|
|
})
|
|
}
|
|
|
|
func TestExtraNetworks_Validate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 3)
|
|
|
|
t.Run("nil extra networks is valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("empty extra networks is valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("valid extra network", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"other": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"shared_file": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("valid extra network with exports_to", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"other": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"shared_file": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("multiple extra networks", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"net_a": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"file_a": nil},
|
|
},
|
|
"net_b": {
|
|
ID: keys[2].Public,
|
|
Files: config.Files{"file_b": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("extra network missing ID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"broken": {
|
|
Files: config.Files{"some_file": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "extra network broken")
|
|
require.ErrorContains(t, err, "network ID is required")
|
|
})
|
|
|
|
t.Run("extra network missing files", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"broken": {
|
|
ID: keys[1].Public,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "extra network broken")
|
|
require.ErrorContains(t, err, "at least one file should be configured")
|
|
})
|
|
|
|
t.Run("extra network invalid file name", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"broken": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"INVALID-NAME": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "extra network broken")
|
|
require.ErrorContains(t, err, "file names must match")
|
|
})
|
|
|
|
t.Run("extra network cannot use reserved 'home' name", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"home": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.ErrorIs(t, err, config.ErrInvalidConfig)
|
|
require.ErrorContains(t, err, "network name 'home' is reserved")
|
|
})
|
|
}
|
|
|
|
func TestConfig_NetworkNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 3)
|
|
|
|
t.Run("default network gets 'home' name", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "home", cfg.Network.Name)
|
|
})
|
|
|
|
t.Run("extra networks get names from map keys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"other_net": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"shared_file": nil},
|
|
},
|
|
"third_net": {
|
|
ID: keys[2].Public,
|
|
Files: config.Files{"another_file": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "other_net", cfg.ExtraNetworks["other_net"].Name)
|
|
require.Equal(t, "third_net", cfg.ExtraNetworks["third_net"].Name)
|
|
})
|
|
}
|
|
|
|
func TestConfig_NetworksByID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 3)
|
|
|
|
t.Run("NetworksByID includes main network", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, cfg.NetworksByID)
|
|
require.Len(t, cfg.NetworksByID, 1)
|
|
|
|
network, ok := cfg.NetworksByID[keys[0].Public.String()]
|
|
require.True(t, ok, "main network should be in NetworksByID")
|
|
require.Equal(t, "home", network.Name)
|
|
require.True(t, network.ID.Equal(keys[0].Public))
|
|
})
|
|
|
|
t.Run("NetworksByID includes extra networks", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/var/lib/data-mesher",
|
|
HTTP: validHTTP(),
|
|
Cluster: validCluster(t, keys[0]),
|
|
Network: validNetwork(t, keys[0].Public),
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"other_net": {
|
|
ID: keys[1].Public,
|
|
Files: config.Files{"shared_file": nil},
|
|
},
|
|
"third_net": {
|
|
ID: keys[2].Public,
|
|
Files: config.Files{"another_file": nil},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, cfg.NetworksByID)
|
|
require.Len(t, cfg.NetworksByID, 3)
|
|
|
|
// Check main network
|
|
network0, ok := cfg.NetworksByID[keys[0].Public.String()]
|
|
require.True(t, ok, "main network should be in NetworksByID")
|
|
require.Equal(t, "home", network0.Name)
|
|
|
|
// Check extra networks
|
|
network1, ok := cfg.NetworksByID[keys[1].Public.String()]
|
|
require.True(t, ok, "other_net should be in NetworksByID")
|
|
require.Equal(t, "other_net", network1.Name)
|
|
|
|
network2, ok := cfg.NetworksByID[keys[2].Public.String()]
|
|
require.True(t, ok, "third_net should be in NetworksByID")
|
|
require.Equal(t, "third_net", network2.Name)
|
|
})
|
|
}
|
|
|
|
func TestFromViper_ExtraNetworks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 4)
|
|
|
|
t.Run("parse extra networks from TOML", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
ck := generateClusterKeys(t, keys[0])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[0].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_zone" = ["` + keys[1].Public.String() + `"]
|
|
|
|
[extra_networks.other_net]
|
|
id = "` + keys[2].Public.String() + `"
|
|
exports_to = ["` + keys[3].Public.String() + `"]
|
|
|
|
[extra_networks.other_net.files]
|
|
"shared_data" = ["` + keys[1].Public.String() + `"]
|
|
`
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Len(cfg.ExtraNetworks, 1)
|
|
|
|
otherNet, ok := cfg.ExtraNetworks["other_net"]
|
|
as.True(ok, "expected extra network 'other_net'")
|
|
as.True(keys[2].Public.Equal(otherNet.ID))
|
|
as.Len(otherNet.Files, 1)
|
|
as.Len(otherNet.Files["shared_data"], 1)
|
|
as.Equal(keys[1].Public.String(), otherNet.Files["shared_data"][0].String())
|
|
})
|
|
|
|
t.Run("parse multiple extra networks from TOML", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
ck := generateClusterKeys(t, keys[0])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[0].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_zone" = ["` + keys[1].Public.String() + `"]
|
|
|
|
[extra_networks.net_a]
|
|
id = "` + keys[2].Public.String() + `"
|
|
|
|
[extra_networks.net_a.files]
|
|
"file_a" = ["` + keys[1].Public.String() + `"]
|
|
|
|
[extra_networks.net_b]
|
|
id = "` + keys[3].Public.String() + `"
|
|
|
|
[extra_networks.net_b.files]
|
|
"file_b" = ["` + keys[1].Public.String() + `"]
|
|
`
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
|
|
as.Len(cfg.ExtraNetworks, 2)
|
|
|
|
netA, ok := cfg.ExtraNetworks["net_a"]
|
|
as.True(ok, "expected extra network 'net_a'")
|
|
as.True(keys[2].Public.Equal(netA.ID))
|
|
as.Len(netA.Files, 1)
|
|
|
|
netB, ok := cfg.ExtraNetworks["net_b"]
|
|
as.True(ok, "expected extra network 'net_b'")
|
|
as.True(keys[3].Public.Equal(netB.ID))
|
|
as.Len(netB.Files, 1)
|
|
})
|
|
|
|
t.Run("no extra networks is valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
ck := generateClusterKeys(t, keys[0])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[0].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_zone" = ["` + keys[1].Public.String() + `"]
|
|
`
|
|
cfg := readConfigFromTempFile(t, toml)
|
|
as.Nil(cfg.ExtraNetworks)
|
|
})
|
|
}
|
|
|
|
// TestFromViper_ConfigurationDirectory tests that CONFIGURATION_DIRECTORY env var
|
|
// is used to discover config files. Note: STATE_DIRECTORY and other env var tests
|
|
// are in env_mapping_test.go.
|
|
func TestFromViper_ConfigurationDirectory(t *testing.T) {
|
|
keys := test.GenerateKeys(t, 2)
|
|
ck := generateClusterKeys(t, keys[1])
|
|
|
|
toml := `
|
|
state_directory = "/var/lib/data-mesher"
|
|
|
|
[http]
|
|
port = 8080
|
|
interfaces = ["lo"]
|
|
|
|
[cluster]
|
|
port = 7946
|
|
interfaces = ["lo"]
|
|
|
|
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
|
|
push_pull_interval = "15s"
|
|
|
|
identity_key = "` + ck.IdentityKeyPath + `"
|
|
identity_cert = "` + ck.CertPath + `"
|
|
|
|
[network]
|
|
id = "` + keys[1].Public.String() + `"
|
|
|
|
[network.files]
|
|
"dns_test" = ["` + keys[0].Public.String() + `"]
|
|
`
|
|
as := require.New(t)
|
|
|
|
// Create a temp directory to act as the configuration directory
|
|
tempDir := t.TempDir()
|
|
configPath := tempDir + "/dm.toml"
|
|
as.NoError(os.WriteFile(configPath, []byte(toml), 0o600))
|
|
|
|
// Set CONFIGURATION_DIRECTORY to our temp directory
|
|
t.Setenv("CONFIGURATION_DIRECTORY", tempDir)
|
|
|
|
// Create a new viper instance and read config
|
|
v, err := config.NewViper()
|
|
as.NoError(err)
|
|
|
|
// ReadInConfig should find dm.toml in CONFIGURATION_DIRECTORY
|
|
as.NoError(v.ReadInConfig())
|
|
|
|
// Verify it read from our temp directory
|
|
as.Equal(configPath, v.ConfigFileUsed())
|
|
}
|
|
|
|
func TestNetwork_NamespacesValidation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
t.Run("valid namespaces", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
n := config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{"test_file": nil},
|
|
Namespaces: []string{"dns", "nss"},
|
|
}
|
|
require.NoError(t, n.Validate())
|
|
})
|
|
|
|
t.Run("empty namespaces is valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
n := config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{"test_file": nil},
|
|
}
|
|
require.NoError(t, n.Validate())
|
|
})
|
|
|
|
t.Run("invalid namespace name", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, name := range []string{"DNS", "my-ns", "a/b", ""} {
|
|
n := config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{"test_file": nil},
|
|
Namespaces: []string{name},
|
|
}
|
|
require.ErrorIs(t, n.Validate(), config.ErrInvalidConfig, "expected %q to be invalid", name)
|
|
}
|
|
})
|
|
|
|
t.Run("duplicate namespace", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
n := config.Network{
|
|
ID: keys[0].Public,
|
|
Files: config.Files{"test_file": nil},
|
|
Namespaces: []string{"dns", "dns"},
|
|
}
|
|
require.ErrorIs(t, n.Validate(), config.ErrInvalidConfig)
|
|
require.ErrorContains(t, n.Validate(), "duplicate namespace")
|
|
})
|
|
}
|