From 2cdbee62f2fcd6ec97f6e8e1cbea12ae4e40e09a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 00:36:08 +0000 Subject: [PATCH] Add tests for ipn/store/kubestore and envknob - ipn/store/kubestore: Add comprehensive tests for sanitizeKey function - All valid/invalid character handling - Kubernetes naming restrictions - Unicode and special character replacement - Idempotent behavior - Performance benchmarks - envknob: Add comprehensive tests for environment variable handling - Bool, String, OptBool functions - Registration mechanism for all types - Setenv and LogCurrent - Integration tests for multiple variable types - Performance benchmarks --- envknob/envknob_test.go | 328 +++++++++++++++++++++++++ ipn/store/kubestore/store_kube_test.go | 267 ++++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 envknob/envknob_test.go create mode 100644 ipn/store/kubestore/store_kube_test.go diff --git a/envknob/envknob_test.go b/envknob/envknob_test.go new file mode 100644 index 000000000..044a35b98 --- /dev/null +++ b/envknob/envknob_test.go @@ -0,0 +1,328 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package envknob + +import ( + "os" + "testing" + "time" + + "tailscale.com/types/opt" +) + +func TestBool(t *testing.T) { + tests := []struct { + name string + envVar string + value string + want bool + wantSet bool + }{ + {name: "true", envVar: "TEST_BOOL_TRUE", value: "true", want: true, wantSet: true}, + {name: "false", envVar: "TEST_BOOL_FALSE", value: "false", want: false, wantSet: true}, + {name: "1", envVar: "TEST_BOOL_1", value: "1", want: true, wantSet: true}, + {name: "0", envVar: "TEST_BOOL_0", value: "0", want: false, wantSet: true}, + {name: "unset", envVar: "TEST_BOOL_UNSET", value: "", want: false, wantSet: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != "" { + os.Setenv(tt.envVar, tt.value) + defer os.Unsetenv(tt.envVar) + } + + got := Bool(tt.envVar) + if got != tt.want { + t.Errorf("Bool(%q) = %v, want %v", tt.envVar, got, tt.want) + } + }) + } +} + +func TestBoolDefaultTrue(t *testing.T) { + envVar := "TEST_BOOL_DEFAULT_TRUE" + + // Unset - should return true + os.Unsetenv(envVar) + if got := BoolDefaultTrue(envVar); !got { + t.Errorf("BoolDefaultTrue(%q) with unset = %v, want true", envVar, got) + } + + // Set to false - should return false + os.Setenv(envVar, "false") + defer os.Unsetenv(envVar) + if got := BoolDefaultTrue(envVar); got { + t.Errorf("BoolDefaultTrue(%q) with false = %v, want false", envVar, got) + } +} + +func TestGOOS(t *testing.T) { + // Should return a non-empty string + if got := GOOS(); got == "" { + t.Error("GOOS() returned empty string") + } + + // By default should match runtime.GOOS + if got := GOOS(); got != os.Getenv("GOOS") && os.Getenv("GOOS") == "" { + // If GOOS env var not set, should use runtime + // Can't test exact value as it's platform-dependent + } +} + +func TestString(t *testing.T) { + tests := []struct { + name string + envVar string + value string + want string + }{ + {name: "set", envVar: "TEST_STRING", value: "hello", want: "hello"}, + {name: "empty", envVar: "TEST_STRING_EMPTY", value: "", want: ""}, + {name: "spaces", envVar: "TEST_STRING_SPACES", value: " value ", want: " value "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != "" { + os.Setenv(tt.envVar, tt.value) + defer os.Unsetenv(tt.envVar) + } + + got := String(tt.envVar) + if got != tt.want { + t.Errorf("String(%q) = %q, want %q", tt.envVar, got, tt.want) + } + }) + } +} + +func TestOptBool(t *testing.T) { + tests := []struct { + name string + envVar string + value string + wantSet bool + wantVal bool + }{ + {name: "true", envVar: "TEST_OPT_TRUE", value: "true", wantSet: true, wantVal: true}, + {name: "false", envVar: "TEST_OPT_FALSE", value: "false", wantSet: true, wantVal: false}, + {name: "unset", envVar: "TEST_OPT_UNSET", value: "", wantSet: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value != "" { + os.Setenv(tt.envVar, tt.value) + defer os.Unsetenv(tt.envVar) + } else { + os.Unsetenv(tt.envVar) + } + + got := OptBool(tt.envVar) + if _, ok := got.Get(); ok != tt.wantSet { + t.Errorf("OptBool(%q).Get() set = %v, want %v", tt.envVar, ok, tt.wantSet) + } + if tt.wantSet { + if val, _ := got.Get(); val != tt.wantVal { + t.Errorf("OptBool(%q).Get() value = %v, want %v", tt.envVar, val, tt.wantVal) + } + } + }) + } +} + +func TestSetenv(t *testing.T) { + envVar := "TEST_SETENV" + value := "test_value" + + defer os.Unsetenv(envVar) + + Setenv(envVar, value) + + // Verify it's actually set in the environment + if got := os.Getenv(envVar); got != value { + t.Errorf("After Setenv, os.Getenv(%q) = %q, want %q", envVar, got, value) + } + + // Verify String retrieves it + if got := String(envVar); got != value { + t.Errorf("After Setenv, String(%q) = %q, want %q", envVar, got, value) + } +} + +func TestRegisterString(t *testing.T) { + envVar := "TEST_REGISTER_STRING" + value := "registered" + + os.Setenv(envVar, value) + defer os.Unsetenv(envVar) + + var target string + RegisterString(&target, envVar) + + if target != value { + t.Errorf("After RegisterString, target = %q, want %q", target, value) + } +} + +func TestRegisterBool(t *testing.T) { + envVar := "TEST_REGISTER_BOOL" + + os.Setenv(envVar, "true") + defer os.Unsetenv(envVar) + + var target bool + RegisterBool(&target, envVar) + + if !target { + t.Error("After RegisterBool with true, target = false, want true") + } +} + +func TestRegisterOptBool(t *testing.T) { + envVar := "TEST_REGISTER_OPTBOOL" + + os.Setenv(envVar, "true") + defer os.Unsetenv(envVar) + + var target opt.Bool + RegisterOptBool(&target, envVar) + + if val, ok := target.Get(); !ok || !val { + t.Errorf("After RegisterOptBool, target = (%v, %v), want (true, true)", val, ok) + } +} + +func TestLogCurrent(t *testing.T) { + // Set a test env var + os.Setenv("TEST_LOG_CURRENT", "test") + defer os.Unsetenv("TEST_LOG_CURRENT") + + // Force it to be noted + Setenv("TEST_LOG_CURRENT", "test") + + logged := false + logf := func(format string, args ...any) { + logged = true + } + + LogCurrent(logf) + + if !logged { + t.Error("LogCurrent did not call logf") + } +} + +func TestUseRunningUserForAuth(t *testing.T) { + // This just tests that the function runs without panicking + defer func() { + if r := recover(); r != nil { + t.Errorf("UseRunningUserForAuth() panicked: %v", r) + } + }() + + _ = UseRunningUserForAuth() +} + +func TestDERPConncap(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("DERPConncap() panicked: %v", r) + } + }() + + got := DERPConncap() + if got < 0 { + t.Errorf("DERPConncap() = %d, want >= 0", got) + } +} + +// Test some known environment variables +func TestKnownVariables(t *testing.T) { + // These functions should not panic + _ = CrashMonitorSupport() + _ = NoLogsNoSupport() + _ = AllowRemoteUpdate() + _ = DisablePortMapper() +} + +// Benchmark common operations +func BenchmarkBool(b *testing.B) { + os.Setenv("BENCH_BOOL", "true") + defer os.Unsetenv("BENCH_BOOL") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = Bool("BENCH_BOOL") + } +} + +func BenchmarkString(b *testing.B) { + os.Setenv("BENCH_STRING", "value") + defer os.Unsetenv("BENCH_STRING") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = String("BENCH_STRING") + } +} + +func BenchmarkOptBool(b *testing.B) { + os.Setenv("BENCH_OPTBOOL", "true") + defer os.Unsetenv("BENCH_OPTBOOL") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = OptBool("BENCH_OPTBOOL") + } +} + +// Integration test for registering variables +func TestRegisterIntegration(t *testing.T) { + // Test registering multiple types + var ( + strVal string + boolVal bool + optVal opt.Bool + durVal time.Duration + intVal int + ) + + os.Setenv("TEST_INT_STR", "hello") + os.Setenv("TEST_INT_BOOL", "true") + os.Setenv("TEST_INT_OPT", "false") + os.Setenv("TEST_INT_DUR", "5s") + os.Setenv("TEST_INT_INT", "42") + + defer func() { + os.Unsetenv("TEST_INT_STR") + os.Unsetenv("TEST_INT_BOOL") + os.Unsetenv("TEST_INT_OPT") + os.Unsetenv("TEST_INT_DUR") + os.Unsetenv("TEST_INT_INT") + }() + + RegisterString(&strVal, "TEST_INT_STR") + RegisterBool(&boolVal, "TEST_INT_BOOL") + RegisterOptBool(&optVal, "TEST_INT_OPT") + RegisterDuration(&durVal, "TEST_INT_DUR") + RegisterInt(&intVal, "TEST_INT_INT") + + if strVal != "hello" { + t.Errorf("strVal = %q, want %q", strVal, "hello") + } + if !boolVal { + t.Error("boolVal = false, want true") + } + if val, ok := optVal.Get(); !ok || val { + t.Errorf("optVal = (%v, %v), want (false, true)", val, ok) + } + if durVal != 5*time.Second { + t.Errorf("durVal = %v, want 5s", durVal) + } + if intVal != 42 { + t.Errorf("intVal = %d, want 42", intVal) + } +} diff --git a/ipn/store/kubestore/store_kube_test.go b/ipn/store/kubestore/store_kube_test.go new file mode 100644 index 000000000..4f68f8e95 --- /dev/null +++ b/ipn/store/kubestore/store_kube_test.go @@ -0,0 +1,267 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package kubestore + +import ( + "strings" + "testing" + + "tailscale.com/ipn" +) + +func TestStore_String(t *testing.T) { + s := &Store{ + secretName: "test-secret", + } + + if got := s.String(); got != "kube.Store" { + t.Errorf("String() = %q, want %q", got, "kube.Store") + } +} + +func TestSanitizeKey(t *testing.T) { + tests := []struct { + name string + input ipn.StateKey + want string + }{ + { + name: "alphanumeric", + input: "abc123", + want: "abc123", + }, + { + name: "with_dashes", + input: "test-key-name", + want: "test-key-name", + }, + { + name: "with_underscores", + input: "test_key_name", + want: "test_key_name", + }, + { + name: "with_dots", + input: "test.key.name", + want: "test.key.name", + }, + { + name: "with_invalid_chars", + input: "test/key:name", + want: "test_key_name", + }, + { + name: "with_spaces", + input: "test key name", + want: "test_key_name", + }, + { + name: "with_special_chars", + input: "test@key#name", + want: "test_key_name", + }, + { + name: "mixed_case", + input: "TestKeyName", + want: "TestKeyName", + }, + { + name: "all_invalid", + input: "@#$%^&*()", + want: "_________", + }, + { + name: "empty", + input: "", + want: "", + }, + { + name: "path_like", + input: "/var/lib/tailscale/state", + want: "_var_lib_tailscale_state", + }, + { + name: "url_like", + input: "https://example.com/path?query=value", + want: "https___example.com_path_query_value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeKey(tt.input) + if got != tt.want { + t.Errorf("sanitizeKey(%q) = %q, want %q", tt.input, got, tt.want) + } + + // Verify result contains only valid characters + for _, r := range got { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') { + t.Errorf("sanitizeKey(%q) = %q contains invalid char %c", tt.input, got, r) + } + } + }) + } +} + +func TestSanitizeKey_Idempotent(t *testing.T) { + // Sanitizing a key twice should produce the same result + tests := []ipn.StateKey{ + "valid-key", + "invalid/key", + "test@key#name", + "path/to/state", + } + + for _, key := range tests { + first := sanitizeKey(key) + second := sanitizeKey(ipn.StateKey(first)) + + if first != second { + t.Errorf("sanitizeKey not idempotent for %q: first=%q, second=%q", key, first, second) + } + } +} + +func TestSanitizeKey_PreservesValidChars(t *testing.T) { + // All valid characters should pass through unchanged + validChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_." + result := sanitizeKey(ipn.StateKey(validChars)) + + if result != validChars { + t.Errorf("sanitizeKey(%q) = %q, want %q", validChars, result, validChars) + } +} + +func TestSanitizeKey_Length(t *testing.T) { + // Length should be preserved + tests := []ipn.StateKey{ + "short", + "a-very-long-key-name-that-has-many-characters-in-it", + "x", + "", + } + + for _, key := range tests { + result := sanitizeKey(key) + if len(result) != len(string(key)) { + t.Errorf("sanitizeKey(%q) length = %d, want %d", key, len(result), len(string(key))) + } + } +} + +func TestStore_SetDialer(t *testing.T) { + // This test verifies SetDialer doesn't panic + // Full testing would require mocking kubeclient.Client + s := &Store{ + secretName: "test-secret", + } + + // Should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("SetDialer panicked: %v", r) + } + }() + + s.SetDialer(nil) +} + +func TestSanitizeKey_Unicode(t *testing.T) { + // Unicode characters should be replaced with underscore + tests := []struct { + input string + desc string + }{ + {input: "hello世界", desc: "Chinese characters"}, + {input: "тест", desc: "Cyrillic characters"}, + {input: "café", desc: "Accented characters"}, + {input: "🔑key", desc: "Emoji"}, + {input: "αβγ", desc: "Greek letters"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + result := sanitizeKey(ipn.StateKey(tt.input)) + + // Should only contain valid chars + for _, r := range result { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') { + t.Errorf("sanitizeKey(%q) = %q contains invalid char %c", tt.input, result, r) + } + } + + // Should contain at least some underscores (since we replaced unicode) + if !strings.Contains(result, "_") && len(tt.input) > 0 { + t.Errorf("sanitizeKey(%q) = %q, expected underscores for unicode replacement", tt.input, result) + } + }) + } +} + +func TestSanitizeKey_KubernetesRestrictions(t *testing.T) { + // Test that sanitized keys would be valid Kubernetes secret keys + tests := []ipn.StateKey{ + "simple", + "with-dash", + "with_underscore", + "with.dot", + "MixedCase123", + "has/slash", + "has:colon", + "has spaces", + "has@symbols#here", + } + + for _, key := range tests { + result := sanitizeKey(key) + + // Kubernetes secret keys must: + // - consist of alphanumeric characters, '-', '_' or '.' + // This is what our sanitizeKey function ensures + for _, r := range result { + valid := (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' || r == '.' + + if !valid { + t.Errorf("sanitizeKey(%q) = %q contains Kubernetes-invalid char %c", key, result, r) + } + } + } +} + +// Benchmark sanitizeKey performance +func BenchmarkSanitizeKey(b *testing.B) { + keys := []ipn.StateKey{ + "simple-key", + "path/to/state/file", + "https://example.com/path?query=value", + "key-with-many-invalid-@#$%-characters", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sanitizeKey(keys[i%len(keys)]) + } +} + +func BenchmarkSanitizeKey_ValidOnly(b *testing.B) { + key := ipn.StateKey("valid-key-123.with_valid.chars") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sanitizeKey(key) + } +} + +func BenchmarkSanitizeKey_AllInvalid(b *testing.B) { + key := ipn.StateKey("@#$%^&*()/\\:;'\"<>?,") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sanitizeKey(key) + } +}