mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 19:29:08 +02:00
The test comment described the null state in the present tense — "the config a plain login writes leaves every one of them unset" — which was true before this branch and is not any more: apply() now resolves those fields, so a login writes them set. unsetOnDisk puts the null state back deliberately, to stand in for a profile an older client wrote. Comments only.
494 lines
19 KiB
Go
494 lines
19 KiB
Go
package profilemanager
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
)
|
|
|
|
func seededConfig(t *testing.T) *Config {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "seeded.json")
|
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
PreSharedKey: strPointer("stored-key"),
|
|
})
|
|
require.NoError(t, err)
|
|
return cfg
|
|
}
|
|
|
|
func strPointer(s string) *string { return &s }
|
|
|
|
func intPtr(i int) *int { return &i }
|
|
|
|
func TestWouldChange(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input ConfigInput
|
|
want bool
|
|
}{
|
|
{name: "empty input", input: ConfigInput{}, want: false},
|
|
{name: "same management URL", input: ConfigInput{ManagementURL: "https://api.netbird.io:443"}, want: false},
|
|
{name: "management URL without its default port", input: ConfigInput{ManagementURL: "https://api.netbird.io"}, want: false},
|
|
{name: "different management URL", input: ConfigInput{ManagementURL: "https://other.example:443"}, want: true},
|
|
{name: "same pre-shared key", input: ConfigInput{PreSharedKey: strPointer("stored-key")}, want: false},
|
|
{name: "redacted pre-shared key", input: ConfigInput{PreSharedKey: strPointer("**********")}, want: false},
|
|
{name: "different pre-shared key", input: ConfigInput{PreSharedKey: strPointer("other-key")}, want: true},
|
|
{name: "new interface blacklist entry", input: ConfigInput{ExtraIFaceBlackList: []string{"nb-probe0"}}, want: true},
|
|
{name: "blacklist entry already present", input: ConfigInput{ExtraIFaceBlackList: []string{"lo"}}, want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
|
|
changed, err := cfg.WouldChange(tt.input)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.want, changed)
|
|
})
|
|
}
|
|
}
|
|
|
|
// The dry run must not be observable on the config it is run against: it
|
|
// decides whether a write is allowed, it does not perform one.
|
|
func TestWouldChangeLeavesTheConfigAlone(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
blacklist := len(cfg.IFaceBlackList)
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{
|
|
ManagementURL: "https://other.example:443",
|
|
PreSharedKey: strPointer("other-key"),
|
|
ExtraIFaceBlackList: []string{"nb-probe0"},
|
|
DNSLabels: domain.FromPunycodeList([]string{"probe"}),
|
|
NATExternalIPs: []string{"1.2.3.4"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
|
|
require.Equal(t, "https://api.netbird.io:443", cfg.ManagementURL.String())
|
|
require.Equal(t, "stored-key", cfg.PreSharedKey)
|
|
require.Len(t, cfg.IFaceBlackList, blacklist)
|
|
require.Empty(t, cfg.DNSLabels)
|
|
require.Empty(t, cfg.NATExternalIPs)
|
|
}
|
|
|
|
// A nil config means the profile holds nothing yet, so the baseline is what
|
|
// the daemon would create for it.
|
|
func TestWouldChangeWithoutAStoredConfig(t *testing.T) {
|
|
var cfg *Config
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "a request carrying nothing cannot change anything")
|
|
|
|
changed, err = cfg.WouldChange(ConfigInput{ManagementURL: DefaultManagementURL})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "the default management URL is what would be written anyway")
|
|
|
|
changed, err = cfg.WouldChange(ConfigInput{ManagementURL: "https://other.example:443"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
}
|
|
|
|
func TestWouldChangeReportsAnInvalidInput(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
|
|
_, err := cfg.WouldChange(ConfigInput{ManagementURL: "not-a-url"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// Reads must not write. A config file missing a field apply() fills in (MTU,
|
|
// here) is what used to trigger the write-back.
|
|
func TestReadsDoNotWriteTheConfigBack(t *testing.T) {
|
|
denormalized := []byte(`{"WgIface":"wt0"}`)
|
|
|
|
for name, read := range map[string]func(string) (*Config, error){
|
|
"GetExistingConfig": GetExistingConfig,
|
|
"ReadOrGenerateConfig": ReadOrGenerateConfig,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "profile.json")
|
|
require.NoError(t, os.WriteFile(path, denormalized, 0o600))
|
|
|
|
cfg, err := read(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint16(iface.DefaultMTU), cfg.MTU, "the returned config is still normalized in memory")
|
|
require.Empty(t, cfg.PrivateKey, "a read must not mint an identity either")
|
|
|
|
after, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(denormalized), string(after), "%s rewrote the config file", name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ReadConfig resolves a default config for a profile that has no file yet, and
|
|
// that must not create the file either.
|
|
func TestReadConfigDoesNotCreateTheFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "absent.json")
|
|
|
|
cfg, err := ReadOrGenerateConfig(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
|
|
|
_, err = os.Stat(path)
|
|
require.True(t, os.IsNotExist(err), "ReadConfig created the config file")
|
|
}
|
|
|
|
// The identity is the one thing a read cannot recompute, so it is provisioned
|
|
// on request and its caller persists it.
|
|
func TestEnsureIdentity(t *testing.T) {
|
|
cfg := newConfigSkeleton()
|
|
|
|
generated, err := cfg.EnsureIdentity()
|
|
require.NoError(t, err)
|
|
require.True(t, generated)
|
|
require.NotEmpty(t, cfg.PrivateKey)
|
|
require.NotEmpty(t, cfg.SSHKey)
|
|
|
|
key := cfg.PrivateKey
|
|
generated, err = cfg.EnsureIdentity()
|
|
require.NoError(t, err)
|
|
require.False(t, generated, "a config that already has an identity keeps it")
|
|
require.Equal(t, key, cfg.PrivateKey)
|
|
}
|
|
|
|
// One endpoint written several ways is one endpoint. A gate that compared
|
|
// spellings refused a client restating its own management URL with a trailing
|
|
// slash, which is a normal way to write it.
|
|
func TestSameServiceURL(t *testing.T) {
|
|
tests := []struct {
|
|
a, b string
|
|
want bool
|
|
}{
|
|
{a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true},
|
|
{a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true},
|
|
{a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true},
|
|
{a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true},
|
|
{a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true},
|
|
{a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false},
|
|
{a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false},
|
|
{a: "https://mgmt.example.com", b: "https://other.example.com", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.a+" vs "+tt.b, func(t *testing.T) {
|
|
a, err := ParseServiceURL("a", tt.a)
|
|
require.NoError(t, err)
|
|
b, err := ParseServiceURL("b", tt.b)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, tt.want, SameServiceURL(a, b))
|
|
require.Equal(t, tt.want, SameServiceURL(b, a), "the comparison must be symmetric")
|
|
})
|
|
}
|
|
}
|
|
|
|
// The same spellings, through the dry run the update-settings gate uses.
|
|
func TestWouldChangeIgnoresURLSpelling(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "seeded.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://mgmt.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
for _, spelling := range []string{
|
|
"https://mgmt.example.com",
|
|
"https://mgmt.example.com/",
|
|
"https://mgmt.example.com:443",
|
|
"https://mgmt.example.com:443/",
|
|
"https://MGMT.example.com",
|
|
} {
|
|
changed, err := cfg.WouldChange(ConfigInput{ManagementURL: spelling})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "%q is the stored endpoint written differently", spelling)
|
|
}
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{ManagementURL: "https://mgmt.example.com:8443"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a different port is a different endpoint")
|
|
}
|
|
|
|
// The dry-run baseline exists to be compared against and discarded, so it must
|
|
// not mint keys — the CLI's login backoff loop would otherwise log a fresh
|
|
// "generated new Wireguard key" on every attempt.
|
|
func TestDryRunBaselineDoesNotGenerateKeys(t *testing.T) {
|
|
baseline, err := newDryRunBaseline(filepath.Join(t.TempDir(), "absent.json"))
|
|
require.NoError(t, err)
|
|
|
|
require.Empty(t, baseline.PrivateKey, "generated a WireGuard key for a throwaway config")
|
|
require.Empty(t, baseline.SSHKey, "generated an SSH key for a throwaway config")
|
|
|
|
// Everything the comparison actually looks at is still the default config.
|
|
require.Equal(t, DefaultManagementURL, baseline.ManagementURL.String())
|
|
require.Equal(t, uint16(iface.DefaultMTU), baseline.MTU)
|
|
require.Equal(t, iface.DefaultWgPort, baseline.WgPort)
|
|
}
|
|
|
|
// A stored profile can carry no identity — a mobile logout clears the keys in
|
|
// place — so the next config write has to mint one, which is what keeps the
|
|
// following login from dialing management with an empty key.
|
|
func TestUpdateConfigProvisionsAMissingIdentity(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "logged-out.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Stand in for the logout, which zeroes the keys and writes the config out.
|
|
loggedOut, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
loggedOut.PrivateKey = ""
|
|
loggedOut.SSHKey = ""
|
|
require.NoError(t, WriteOutConfig(path, loggedOut))
|
|
|
|
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cfg.PrivateKey, "the write path did not provision an identity")
|
|
require.NotEmpty(t, cfg.SSHKey)
|
|
|
|
persisted, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cfg.PrivateKey, persisted.PrivateKey, "the provisioned identity was not persisted")
|
|
}
|
|
|
|
// A config that carries no sync message version must not make the dry run
|
|
// panic: the gate runs inside a request handler, where failing closed is the
|
|
// worst acceptable outcome.
|
|
func TestWouldChangeWithoutAStoredSyncMessageVersion(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
require.Nil(t, cfg.SyncMessageVersion, "the fixture is only useful while the field starts out unset")
|
|
|
|
version := 2
|
|
changed, err := cfg.WouldChange(ConfigInput{SyncMessageVersion: &version})
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
require.Nil(t, cfg.SyncMessageVersion, "the dry run set the version on the stored config")
|
|
}
|
|
|
|
// Restating the certificate paths a config already holds is not a change, for
|
|
// the same reason restating any other value is not.
|
|
func TestWouldChangeIgnoresRestatedCertificatePaths(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "mtls.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
ClientCertPath: "/etc/netbird/client.crt",
|
|
ClientCertKeyPath: "/etc/netbird/client.key",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{
|
|
ClientCertPath: "/etc/netbird/client.crt",
|
|
ClientCertKeyPath: "/etc/netbird/client.key",
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "the stored certificate paths were restated")
|
|
|
|
changed, err = cfg.WouldChange(ConfigInput{ClientCertPath: "/etc/netbird/other.crt"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a different certificate path is a change")
|
|
}
|
|
|
|
// A read that lands on a missing file must not hand back keys: nothing would
|
|
// write them down, so the caller would connect with an identity that changes on
|
|
// the next run and registers a second peer.
|
|
func TestReadOrGenerateConfigCarriesNoIdentity(t *testing.T) {
|
|
cfg, err := ReadOrGenerateConfig(filepath.Join(t.TempDir(), "absent.json"))
|
|
require.NoError(t, err)
|
|
|
|
require.Empty(t, cfg.PrivateKey, "a read minted a WireGuard key")
|
|
require.Empty(t, cfg.SSHKey, "a read minted an SSH key")
|
|
|
|
// So the caller's own EnsureIdentity is the one that reports the work, and
|
|
// therefore the one that triggers the write.
|
|
generated, err := cfg.EnsureIdentity()
|
|
require.NoError(t, err)
|
|
require.True(t, generated, "the provisioning caller could not tell it had to persist the identity")
|
|
}
|
|
|
|
// CreateInMemoryConfig is the opposite contract: its callers connect with what
|
|
// they get back, so it does carry an identity.
|
|
func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) {
|
|
cfg, err := CreateInMemoryConfig(ConfigInput{ManagementURL: "https://api.netbird.io:443"})
|
|
require.NoError(t, err)
|
|
|
|
require.NotEmpty(t, cfg.PrivateKey)
|
|
require.NotEmpty(t, cfg.SSHKey)
|
|
}
|
|
|
|
// The admin panel is opened, not dialed, so its path identifies it. Comparing
|
|
// it as a bare endpoint left a custom panel URL unable to change.
|
|
func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "panel.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
AdminURL: "https://app.example.com/netbird",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String())
|
|
|
|
// Equivalent spellings of the same panel are still not a change.
|
|
for _, same := range []string{
|
|
"https://app.example.com/netbird",
|
|
"https://app.example.com:443/netbird",
|
|
"https://app.example.com/netbird/",
|
|
"https://APP.example.com/netbird",
|
|
} {
|
|
changed, err := cfg.WouldChange(ConfigInput{AdminURL: same})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "%q is the stored panel written differently", same)
|
|
}
|
|
|
|
// A different path is a different panel, and it must be persisted.
|
|
changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a different panel path is a change")
|
|
|
|
updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted")
|
|
}
|
|
|
|
// unsetOnDisk rewrites the stored config so the named fields carry a JSON null,
|
|
// which is how a profile written before apply() resolved them looks on disk.
|
|
// It synthesizes that state: no write produces it any more.
|
|
func unsetOnDisk(t *testing.T, path string, fields ...string) {
|
|
t.Helper()
|
|
|
|
raw, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
|
|
var stored map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(raw, &stored))
|
|
|
|
for _, field := range fields {
|
|
_, present := stored[field]
|
|
require.True(t, present, "%s is not a field of the stored config", field)
|
|
stored[field] = json.RawMessage("null")
|
|
}
|
|
|
|
rewritten, err := json.Marshal(stored)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(path, rewritten, 0600))
|
|
}
|
|
|
|
// Seven fields mean "the effective default" when they hold no value, and every
|
|
// profile written before apply() resolved them holds them as null. Restating
|
|
// that default is asking for no change — and the CLI restates it on every
|
|
// `netbird up`, because a flag set through an environment variable is a flag
|
|
// pflag reports as Changed. Judging those restatements as changes made the
|
|
// update-settings gate refuse `netbird up` outright for a client configured
|
|
// through the environment, which is the shape of a Kubernetes deployment.
|
|
//
|
|
// A login now writes those fields set, so the fixture puts the null state back
|
|
// on disk with unsetOnDisk instead of getting it from a login.
|
|
func TestWouldChangeIgnoresRestatedDefaultsOfUnsetFields(t *testing.T) {
|
|
networkMonitorDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
|
|
|
tests := []struct {
|
|
field string
|
|
theDefault ConfigInput
|
|
theOtherWay ConfigInput
|
|
}{
|
|
{"EnableSSHRoot",
|
|
ConfigInput{EnableSSHRoot: boolPtr(false)}, ConfigInput{EnableSSHRoot: boolPtr(true)}},
|
|
{"EnableSSHSFTP",
|
|
ConfigInput{EnableSSHSFTP: boolPtr(false)}, ConfigInput{EnableSSHSFTP: boolPtr(true)}},
|
|
{"EnableSSHLocalPortForwarding",
|
|
ConfigInput{EnableSSHLocalPortForwarding: boolPtr(false)}, ConfigInput{EnableSSHLocalPortForwarding: boolPtr(true)}},
|
|
{"EnableSSHRemotePortForwarding",
|
|
ConfigInput{EnableSSHRemotePortForwarding: boolPtr(false)}, ConfigInput{EnableSSHRemotePortForwarding: boolPtr(true)}},
|
|
{"DisableSSHAuth",
|
|
ConfigInput{DisableSSHAuth: boolPtr(false)}, ConfigInput{DisableSSHAuth: boolPtr(true)}},
|
|
{"SSHJWTCacheTTL",
|
|
ConfigInput{SSHJWTCacheTTL: intPtr(0)}, ConfigInput{SSHJWTCacheTTL: intPtr(300)}},
|
|
{"NetworkMonitor",
|
|
ConfigInput{NetworkMonitor: boolPtr(networkMonitorDefault)}, ConfigInput{NetworkMonitor: boolPtr(!networkMonitorDefault)}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.field, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "unset.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
})
|
|
require.NoError(t, err)
|
|
unsetOnDisk(t, path, tt.field)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
changed, err := cfg.WouldChange(tt.theDefault)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "restating the default of an unset %s was judged a change", tt.field)
|
|
|
|
// The gate still has to refuse a request that does ask for something.
|
|
changed, err = cfg.WouldChange(tt.theOtherWay)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "asking for a non-default %s is a change", tt.field)
|
|
})
|
|
}
|
|
}
|
|
|
|
// The verdict must not depend on where the caller got the config from. Readers
|
|
// normalize what they hand out, but apply() signals "I filled in a default"
|
|
// through the same bool as "the input changed something", so a config that
|
|
// never passed through a read would otherwise report a change for an input
|
|
// that asks for nothing.
|
|
func TestWouldChangeNormalizesBeforeMeasuring(t *testing.T) {
|
|
rawConfig := func(t *testing.T) *Config {
|
|
t.Helper()
|
|
|
|
cfg := &Config{WgIface: iface.WgInterfaceDefault}
|
|
require.Nil(t, cfg.ServerSSHAllowed, "the fixture is only useful while the config is not normalized")
|
|
require.Nil(t, cfg.EnableSSHRoot)
|
|
require.Empty(t, cfg.IFaceBlackList)
|
|
return cfg
|
|
}
|
|
|
|
changed, err := rawConfig(t).WouldChange(ConfigInput{})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "an input carrying nothing cannot change anything")
|
|
|
|
changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(false)})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "the default of a field the config never held is not a change")
|
|
|
|
changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(true)})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a non-default value is still a change")
|
|
}
|
|
|
|
// A zero-padded port addresses the same port.
|
|
func TestServiceURLPortIsNormalizedNumerically(t *testing.T) {
|
|
padded, err := ParseServiceURL("padded", "https://mgmt.example.com:0443")
|
|
require.NoError(t, err)
|
|
plain, err := ParseServiceURL("plain", "https://mgmt.example.com:443")
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "443", ServiceURLPort(padded))
|
|
require.True(t, SameServiceURL(padded, plain))
|
|
}
|