mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
423 lines
15 KiB
Go
423 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
// captureMainLog swaps mainLog for a test-local buffer logger and restores
|
|
// the previous logger on cleanup. Asserting on the shared logOutput sink is
|
|
// order-dependent: a test that ran earlier can re-store mainLog (Windows'
|
|
// Test_validInterfaces calls initConsoleLogging), leaving the shared buffer
|
|
// stale for every later test.
|
|
func captureMainLog(t *testing.T) *syncBuffer {
|
|
t.Helper()
|
|
buf := &syncBuffer{}
|
|
core := zapcore.NewCore(
|
|
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
|
|
zapcore.AddSync(buf),
|
|
zap.DebugLevel,
|
|
)
|
|
old := mainLog.Load()
|
|
mainLog.Store(&ctrld.Logger{Logger: zap.New(core)})
|
|
t.Cleanup(func() { mainLog.Store(old) })
|
|
return buf
|
|
}
|
|
|
|
func TestProvisionTokenShapeValid(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
want bool
|
|
}{
|
|
{"too short", "abcde", false},
|
|
{"minimum length", "abcdef", true},
|
|
{"maximum length", strings.Repeat("a", 64), true},
|
|
{"too long", strings.Repeat("a", 65), false},
|
|
{"contains space", "org-v1- abc", false},
|
|
{"contains tab", "org-v1-\tabc", false},
|
|
{"contains newline", "org-v1-\nabc", false},
|
|
{"contains control char", "org-v1-\x00abc", false},
|
|
{"sane with prefix", "org-v1-abcdef123456", true},
|
|
{"sane without prefix", "legacytoken123", true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := provisionTokenShapeValid(tc.token); got != tc.want {
|
|
t.Errorf("provisionTokenShapeValid(%q) = %v, want %v", tc.token, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCdUIDFromProvTokenMalformedToken proves a malformed --cd-org value is
|
|
// classified before any network attempt: no call reaches fetchResolverUIDFn,
|
|
// and the malformed value itself never appears in the persisted message.
|
|
func TestCdUIDFromProvTokenMalformedToken(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
|
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
|
|
|
|
cdUID = ""
|
|
const malformed = "ab cd"
|
|
cdOrg = malformed
|
|
customHostname = ""
|
|
|
|
called := false
|
|
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
|
|
called = true
|
|
return nil, nil
|
|
}
|
|
|
|
if got := cdUIDFromProvToken(); got != "" {
|
|
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
|
|
}
|
|
if called {
|
|
t.Error("fetchResolverUIDFn was called; malformed token must be rejected before any network attempt")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeProvisionTokenMalformed] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeProvisionTokenMalformed])
|
|
}
|
|
r, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("no provision result written: %v", err)
|
|
}
|
|
if r.Code != string(provisionCodeProvisionTokenMalformed) {
|
|
t.Errorf("code = %q, want %q", r.Code, provisionCodeProvisionTokenMalformed)
|
|
}
|
|
if r.Stage != string(provisionStageInput) {
|
|
t.Errorf("stage = %q, want %q", r.Stage, provisionStageInput)
|
|
}
|
|
if strings.Contains(r.Message, malformed) {
|
|
t.Errorf("malformed token leaked into result message: %q", r.Message)
|
|
}
|
|
}
|
|
|
|
// TestCdUIDFromProvTokenPrefixlessTokenProceeds proves a token missing the
|
|
// "org-v1-" prefix is not rejected - only warned about - since legacy codes
|
|
// may lack it.
|
|
func TestCdUIDFromProvTokenPrefixlessTokenProceeds(t *testing.T) {
|
|
_, _ = stubProvisionGlobals(t)
|
|
logBuf := captureMainLog(t)
|
|
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
|
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
|
|
|
|
cdUID = ""
|
|
cdOrg = "legacytoken123"
|
|
customHostname = ""
|
|
|
|
called := false
|
|
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
|
|
called = true
|
|
return &controld.ResolverConfig{UID: "resolved-uid"}, nil
|
|
}
|
|
|
|
if got := cdUIDFromProvToken(); got != "resolved-uid" {
|
|
t.Errorf("cdUIDFromProvToken() = %q, want resolved-uid", got)
|
|
}
|
|
if !called {
|
|
t.Error("fetchResolverUIDFn was not called; a prefixless-but-sane token must still proceed")
|
|
}
|
|
if !strings.Contains(logBuf.String(), "org-v1-") {
|
|
t.Error("expected a warning mentioning the org-v1- prefix")
|
|
}
|
|
}
|
|
|
|
// TestCdUIDFromProvTokenInvalidCustomHostname proves an invalid
|
|
// --custom-hostname value is classified before any network attempt, with a
|
|
// message that names the field and format, and never leaks the provision
|
|
// token.
|
|
func TestCdUIDFromProvTokenInvalidCustomHostname(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
|
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
|
|
|
|
cdUID = ""
|
|
const secretToken = "org-v1-secret-token-42"
|
|
cdOrg = secretToken
|
|
customHostname = "foo@bar"
|
|
|
|
called := false
|
|
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
|
|
called = true
|
|
return nil, nil
|
|
}
|
|
|
|
if got := cdUIDFromProvToken(); got != "" {
|
|
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
|
|
}
|
|
if called {
|
|
t.Error("fetchResolverUIDFn was called; invalid custom hostname must be rejected before any network attempt")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeCustomHostnameInvalid] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeCustomHostnameInvalid])
|
|
}
|
|
r, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("no provision result written: %v", err)
|
|
}
|
|
if r.Code != string(provisionCodeCustomHostnameInvalid) {
|
|
t.Errorf("code = %q, want %q", r.Code, provisionCodeCustomHostnameInvalid)
|
|
}
|
|
if !strings.Contains(r.Message, "allowed format") {
|
|
t.Errorf("message = %q, want it to contain the allowed format", r.Message)
|
|
}
|
|
if strings.Contains(r.Message, secretToken) {
|
|
t.Errorf("provision token leaked into result message: %q", r.Message)
|
|
}
|
|
}
|
|
|
|
// TestCdUIDFromProvTokenFoldableHostnameLogsNotice proves a hostname that
|
|
// ctrld accepts, but ControlD's device-name formatting would fold or strip,
|
|
// gets a notice - not a failure - and provisioning still proceeds.
|
|
func TestCdUIDFromProvTokenFoldableHostnameLogsNotice(t *testing.T) {
|
|
_, _ = stubProvisionGlobals(t)
|
|
logBuf := captureMainLog(t)
|
|
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
|
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
|
|
|
|
cdUID = ""
|
|
cdOrg = "org-v1-abcdef123456"
|
|
customHostname = "foo.bar"
|
|
|
|
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
|
|
return &controld.ResolverConfig{UID: "resolved-uid"}, nil
|
|
}
|
|
|
|
if got := cdUIDFromProvToken(); got != "resolved-uid" {
|
|
t.Errorf("cdUIDFromProvToken() = %q, want resolved-uid", got)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "foo.bar") {
|
|
t.Error("expected a notice naming the foldable hostname")
|
|
}
|
|
}
|
|
|
|
func TestValidateInterceptModeFlag(t *testing.T) {
|
|
oldMode := interceptMode
|
|
t.Cleanup(func() { interceptMode = oldMode })
|
|
|
|
t.Run("valid values proceed", func(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
for _, mode := range []string{"", "off", "dns", "hard"} {
|
|
interceptMode = mode
|
|
if !validateInterceptModeFlag(mode) {
|
|
t.Errorf("validateInterceptModeFlag(%q) = false, want true", mode)
|
|
}
|
|
if *exitCode != -1 {
|
|
t.Errorf("mode %q: provisionExit called with %d, want no exit", mode, *exitCode)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("invalid value is classified", func(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
interceptMode = "fds"
|
|
if validateInterceptModeFlag("fds") {
|
|
t.Error("validateInterceptModeFlag(\"fds\") = true, want false")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeInterceptModeInvalid] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInterceptModeInvalid])
|
|
}
|
|
r, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("no provision result written: %v", err)
|
|
}
|
|
if r.Code != string(provisionCodeInterceptModeInvalid) {
|
|
t.Errorf("code = %q, want %q", r.Code, provisionCodeInterceptModeInvalid)
|
|
}
|
|
for _, want := range []string{"off", "dns", "hard"} {
|
|
if !strings.Contains(r.Message, want) {
|
|
t.Errorf("message = %q, want it to contain %q", r.Message, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCheckStrFlagEmptyClassifiesEmptyCdOrg proves an explicit empty --cd-org
|
|
// is classified as a malformed provisioning token rather than a bare fatal.
|
|
func TestCheckStrFlagEmptyClassifiesEmptyCdOrg(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().String(cdOrgFlagName, "", "")
|
|
if err := cmd.Flags().Set(cdOrgFlagName, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if checkStrFlagEmpty(cmd, cdOrgFlagName) {
|
|
t.Error("checkStrFlagEmpty() = true, want false for an explicit empty --cd-org")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeProvisionTokenMalformed] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeProvisionTokenMalformed])
|
|
}
|
|
r, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("no provision result written: %v", err)
|
|
}
|
|
if r.Code != string(provisionCodeProvisionTokenMalformed) {
|
|
t.Errorf("code = %q, want %q", r.Code, provisionCodeProvisionTokenMalformed)
|
|
}
|
|
if r.Stage != string(provisionStageInput) {
|
|
t.Errorf("stage = %q, want %q", r.Stage, provisionStageInput)
|
|
}
|
|
if !strings.Contains(r.Message, cdOrgFlagName) {
|
|
t.Errorf("message = %q, want it to name --%s", r.Message, cdOrgFlagName)
|
|
}
|
|
}
|
|
|
|
// TestCheckStrFlagEmptyProceedsWhenNotChangedOrNotEmpty proves the two cases
|
|
// that must not classify: the flag was never set, and it was set to a
|
|
// non-empty value.
|
|
func TestCheckStrFlagEmptyProceedsWhenNotChangedOrNotEmpty(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
|
|
t.Run("flag never set", func(t *testing.T) {
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().String(cdOrgFlagName, "", "")
|
|
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
|
|
t.Error("checkStrFlagEmpty() = false, want true when the flag was never set")
|
|
}
|
|
})
|
|
|
|
t.Run("flag set to a non-empty value", func(t *testing.T) {
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().String(cdOrgFlagName, "", "")
|
|
if err := cmd.Flags().Set(cdOrgFlagName, "org-v1-abc"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
|
|
t.Error("checkStrFlagEmpty() = false, want true for a non-empty value")
|
|
}
|
|
})
|
|
|
|
if *exitCode != -1 {
|
|
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
|
|
}
|
|
}
|
|
|
|
// TestValidateCdAndNextDNSFlagsClassifiesConflict proves --cd or --cd-org
|
|
// combined with --nextdns is classified as INVALID_FLAG_COMBINATION, naming
|
|
// every flag involved.
|
|
func TestValidateCdAndNextDNSFlagsClassifiesConflict(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
oldCdUID, oldCdOrg, oldNextdns := cdUID, cdOrg, nextdns
|
|
t.Cleanup(func() { cdUID, cdOrg, nextdns = oldCdUID, oldCdOrg, oldNextdns })
|
|
|
|
t.Run("non-conflicting combinations proceed", func(t *testing.T) {
|
|
cases := []struct{ cdUID, cdOrg, nextdns string }{
|
|
{"", "", ""},
|
|
{"uid123", "", ""},
|
|
{"", "org-v1-abc", ""},
|
|
{"", "", "nextdns-id"},
|
|
}
|
|
for _, tc := range cases {
|
|
cdUID, cdOrg, nextdns = tc.cdUID, tc.cdOrg, tc.nextdns
|
|
if !validateCdAndNextDNSFlags() {
|
|
t.Errorf("validateCdAndNextDNSFlags() = false for cdUID=%q cdOrg=%q nextdns=%q, want true", tc.cdUID, tc.cdOrg, tc.nextdns)
|
|
}
|
|
}
|
|
if *exitCode != -1 {
|
|
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
|
|
}
|
|
})
|
|
|
|
t.Run("cd-org with nextdns is classified", func(t *testing.T) {
|
|
cdUID, cdOrg, nextdns = "", "org-v1-abc", "nextdns-id"
|
|
if validateCdAndNextDNSFlags() {
|
|
t.Error("validateCdAndNextDNSFlags() = true, want false")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeInvalidFlagCombination] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInvalidFlagCombination])
|
|
}
|
|
r, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("no provision result written: %v", err)
|
|
}
|
|
if r.Code != string(provisionCodeInvalidFlagCombination) {
|
|
t.Errorf("code = %q, want %q", r.Code, provisionCodeInvalidFlagCombination)
|
|
}
|
|
if r.Stage != string(provisionStageInput) {
|
|
t.Errorf("stage = %q, want %q", r.Stage, provisionStageInput)
|
|
}
|
|
for _, want := range []string{cdUidFlagName, cdOrgFlagName, nextdnsFlagName} {
|
|
if !strings.Contains(r.Message, want) {
|
|
t.Errorf("message = %q, want it to name --%s", r.Message, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("cd with nextdns is classified", func(t *testing.T) {
|
|
cdUID, cdOrg, nextdns = "uid123", "", "nextdns-id"
|
|
if validateCdAndNextDNSFlags() {
|
|
t.Error("validateCdAndNextDNSFlags() = true, want false")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeInvalidFlagCombination] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInvalidFlagCombination])
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidateCdUpstreamProtocol proves an invalid --proto value is
|
|
// classified only once --cd is in play, and that notify runs when given.
|
|
func TestValidateCdUpstreamProtocol(t *testing.T) {
|
|
exitCode, _ := stubProvisionGlobals(t)
|
|
oldCdUID, oldProto := cdUID, cdUpstreamProto
|
|
t.Cleanup(func() { cdUID, cdUpstreamProto = oldCdUID, oldProto })
|
|
|
|
t.Run("no --cd proceeds regardless of protocol", func(t *testing.T) {
|
|
cdUID = ""
|
|
cdUpstreamProto = "garbage"
|
|
if !validateCdUpstreamProtocol(nil) {
|
|
t.Error("validateCdUpstreamProtocol(nil) = false, want true when --cd is not set")
|
|
}
|
|
if *exitCode != -1 {
|
|
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
|
|
}
|
|
})
|
|
|
|
t.Run("valid protocols proceed", func(t *testing.T) {
|
|
cdUID = "uid123"
|
|
for _, proto := range []string{ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3} {
|
|
cdUpstreamProto = proto
|
|
if !validateCdUpstreamProtocol(nil) {
|
|
t.Errorf("validateCdUpstreamProtocol(nil) = false for proto %q, want true", proto)
|
|
}
|
|
}
|
|
if *exitCode != -1 {
|
|
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid protocol is classified and notifies", func(t *testing.T) {
|
|
cdUID = "uid123"
|
|
cdUpstreamProto = "quic"
|
|
notified := false
|
|
if validateCdUpstreamProtocol(func() { notified = true }) {
|
|
t.Error("validateCdUpstreamProtocol() = true, want false")
|
|
}
|
|
if !notified {
|
|
t.Error("notify not called")
|
|
}
|
|
if *exitCode != provisionExitCodeForCode[provisionCodeInvalidFlagCombination] {
|
|
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInvalidFlagCombination])
|
|
}
|
|
r, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("no provision result written: %v", err)
|
|
}
|
|
if r.Code != string(provisionCodeInvalidFlagCombination) {
|
|
t.Errorf("code = %q, want %q", r.Code, provisionCodeInvalidFlagCombination)
|
|
}
|
|
if !strings.Contains(r.Message, "quic") || !strings.Contains(r.Message, "doh") {
|
|
t.Errorf("message = %q, want it to name the given and allowed values", r.Message)
|
|
}
|
|
})
|
|
}
|