Files
kportal/internal/config/config_getters_test.go
T
lukaszraczylo 23cd45a3d7 improvements nov2025 pt2 (#13)
* Further improvements

| Fix                                | Impact                                 | Files Modified                       |
|------------------------------------|----------------------------------------|--------------------------------------|
| sync.Pool for health check buffers | Reduces GC pressure ~30%               | internal/healthcheck/checker.go      |
| Goroutine leak fix + sync.Once     | Prevents memory leaks                  | internal/forward/worker.go           |
| Cache eviction for expired entries | Prevents unbounded memory growth       | internal/k8s/resolver.go             |
| Backoff reset on success           | Faster recovery after long connections | internal/forward/worker.go           |
| Converter file permissions         | Security hardening (0644→0600)         | internal/converter/kftray.go         |
| HTTP body size limiting            | Prevents OOM with large requests       | internal/httplog/proxy.go, logger.go |
| WaitGroup for config watcher       | Clean goroutine shutdown               | internal/config/watcher.go           |
| Signal handler cleanup             | Ensures all resources released         | cmd/kportal/main.go                  |

* Additional event bus for internal event handling

| Metric                 | Before                                | After             | Improvement        |
|------------------------|---------------------------------------|-------------------|--------------------|
| Goroutines per forward | 3 (worker + heartbeat + health check) | 1 (worker only)   | 66% reduction      |
| Tickers per forward    | 2 (heartbeat + health check)          | 0                 | 100% reduction     |
| Global goroutines      | 2 (watchdog + health monitor)         | 2                 | Same               |
| Lock acquisitions/sec  | O(n) per interval                     | O(1) per interval | Linear improvement |


* Add UI testing
* Add mocks
* Add more logs and details to be displayed
2025-11-26 13:18:50 +00:00

704 lines
16 KiB
Go

package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestParseDurationOrDefault tests the duration parsing helper
func TestParseDurationOrDefault(t *testing.T) {
tests := []struct {
name string
value string
defaultDur time.Duration
expected time.Duration
}{
{"empty string returns default", "", 5 * time.Second, 5 * time.Second},
{"valid duration seconds", "3s", 5 * time.Second, 3 * time.Second},
{"valid duration minutes", "25m", 5 * time.Second, 25 * time.Minute},
{"valid duration hours", "1h", 5 * time.Second, 1 * time.Hour},
{"valid duration milliseconds", "100ms", 5 * time.Second, 100 * time.Millisecond},
{"invalid duration returns default", "invalid", 5 * time.Second, 5 * time.Second},
{"missing unit returns default", "30", 5 * time.Second, 5 * time.Second},
{"negative duration", "-5s", 5 * time.Second, -5 * time.Second}, // time.ParseDuration accepts negative
{"complex duration", "1h30m", 5 * time.Second, 1*time.Hour + 30*time.Minute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDurationOrDefault(tt.value, tt.defaultDur)
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetHealthCheckIntervalOrDefault tests health check interval getter
func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil health check returns default",
config: &Config{},
expected: DefaultHealthCheckInterval,
},
{
name: "empty interval returns default",
config: &Config{
HealthCheck: &HealthCheckSpec{},
},
expected: DefaultHealthCheckInterval,
},
{
name: "valid interval",
config: &Config{
HealthCheck: &HealthCheckSpec{Interval: "5s"},
},
expected: 5 * time.Second,
},
{
name: "invalid interval returns default",
config: &Config{
HealthCheck: &HealthCheckSpec{Interval: "invalid"},
},
expected: DefaultHealthCheckInterval,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetHealthCheckIntervalOrDefault()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetHealthCheckTimeoutOrDefault tests health check timeout getter
func TestConfig_GetHealthCheckTimeoutOrDefault(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil health check returns default",
config: &Config{},
expected: DefaultHealthCheckTimeout,
},
{
name: "empty timeout returns default",
config: &Config{
HealthCheck: &HealthCheckSpec{},
},
expected: DefaultHealthCheckTimeout,
},
{
name: "valid timeout",
config: &Config{
HealthCheck: &HealthCheckSpec{Timeout: "1s"},
},
expected: 1 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetHealthCheckTimeoutOrDefault()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetHealthCheckMethod tests health check method getter
func TestConfig_GetHealthCheckMethod(t *testing.T) {
tests := []struct {
name string
config *Config
expected string
}{
{
name: "nil health check returns default",
config: &Config{},
expected: DefaultHealthCheckMethod,
},
{
name: "empty method returns default",
config: &Config{
HealthCheck: &HealthCheckSpec{},
},
expected: DefaultHealthCheckMethod,
},
{
name: "tcp-dial method",
config: &Config{
HealthCheck: &HealthCheckSpec{Method: "tcp-dial"},
},
expected: "tcp-dial",
},
{
name: "data-transfer method",
config: &Config{
HealthCheck: &HealthCheckSpec{Method: "data-transfer"},
},
expected: "data-transfer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetHealthCheckMethod()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetMaxConnectionAge tests max connection age getter
func TestConfig_GetMaxConnectionAge(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil health check returns default",
config: &Config{},
expected: DefaultMaxConnectionAge,
},
{
name: "empty max age returns default",
config: &Config{
HealthCheck: &HealthCheckSpec{},
},
expected: DefaultMaxConnectionAge,
},
{
name: "valid max age",
config: &Config{
HealthCheck: &HealthCheckSpec{MaxConnectionAge: "20m"},
},
expected: 20 * time.Minute,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetMaxConnectionAge()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetMaxIdleTime tests max idle time getter
func TestConfig_GetMaxIdleTime(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil health check returns default",
config: &Config{},
expected: DefaultMaxIdleTime,
},
{
name: "empty max idle returns default",
config: &Config{
HealthCheck: &HealthCheckSpec{},
},
expected: DefaultMaxIdleTime,
},
{
name: "valid max idle",
config: &Config{
HealthCheck: &HealthCheckSpec{MaxIdleTime: "5m"},
},
expected: 5 * time.Minute,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetMaxIdleTime()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetTCPKeepalive tests TCP keepalive getter
func TestConfig_GetTCPKeepalive(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil reliability returns default",
config: &Config{},
expected: DefaultTCPKeepalive,
},
{
name: "empty keepalive returns default",
config: &Config{
Reliability: &ReliabilitySpec{},
},
expected: DefaultTCPKeepalive,
},
{
name: "valid keepalive",
config: &Config{
Reliability: &ReliabilitySpec{TCPKeepalive: "15s"},
},
expected: 15 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetTCPKeepalive()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetRetryOnStale tests retry on stale getter
func TestConfig_GetRetryOnStale(t *testing.T) {
tests := []struct {
name string
config *Config
expected bool
}{
{
name: "nil reliability returns default true",
config: &Config{},
expected: true,
},
{
name: "explicit false",
config: &Config{
Reliability: &ReliabilitySpec{RetryOnStale: false},
},
expected: false,
},
{
name: "explicit true",
config: &Config{
Reliability: &ReliabilitySpec{RetryOnStale: true},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetRetryOnStale()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetWatchdogPeriod tests watchdog period getter
func TestConfig_GetWatchdogPeriod(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil reliability returns default",
config: &Config{},
expected: DefaultWatchdogPeriod,
},
{
name: "empty period returns default",
config: &Config{
Reliability: &ReliabilitySpec{},
},
expected: DefaultWatchdogPeriod,
},
{
name: "valid period",
config: &Config{
Reliability: &ReliabilitySpec{WatchdogPeriod: "1m"},
},
expected: 1 * time.Minute,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetWatchdogPeriod()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_GetDialTimeout tests dial timeout getter
func TestConfig_GetDialTimeout(t *testing.T) {
tests := []struct {
name string
config *Config
expected time.Duration
}{
{
name: "nil reliability returns default",
config: &Config{},
expected: DefaultDialTimeout,
},
{
name: "empty timeout returns default",
config: &Config{
Reliability: &ReliabilitySpec{},
},
expected: DefaultDialTimeout,
},
{
name: "valid timeout",
config: &Config{
Reliability: &ReliabilitySpec{DialTimeout: "10s"},
},
expected: 10 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.GetDialTimeout()
assert.Equal(t, tt.expected, result)
})
}
}
// TestConfig_IsMDNSEnabled tests mDNS enabled getter
func TestConfig_IsMDNSEnabled(t *testing.T) {
tests := []struct {
name string
config *Config
expected bool
}{
{
name: "nil MDNS returns false",
config: &Config{},
expected: false,
},
{
name: "MDNS disabled",
config: &Config{
MDNS: &MDNSSpec{Enabled: false},
},
expected: false,
},
{
name: "MDNS enabled",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.IsMDNSEnabled()
assert.Equal(t, tt.expected, result)
})
}
}
// TestForward_IsHTTPLogEnabled tests HTTP log enabled check
func TestForward_IsHTTPLogEnabled(t *testing.T) {
tests := []struct {
name string
forward Forward
expected bool
}{
{
name: "nil HTTPLog",
forward: Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080},
expected: false,
},
{
name: "HTTPLog disabled",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
HTTPLog: &HTTPLogSpec{Enabled: false},
},
expected: false,
},
{
name: "HTTPLog enabled",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
HTTPLog: &HTTPLogSpec{Enabled: true},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.forward.IsHTTPLogEnabled()
assert.Equal(t, tt.expected, result)
})
}
}
// TestForward_GetHTTPLogMaxBodySize tests HTTP log max body size
func TestForward_GetHTTPLogMaxBodySize(t *testing.T) {
tests := []struct {
name string
forward Forward
expected int
}{
{
name: "nil HTTPLog returns default",
forward: Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080},
expected: DefaultHTTPLogMaxBodySize,
},
{
name: "zero max body size returns default",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
HTTPLog: &HTTPLogSpec{MaxBodySize: 0},
},
expected: DefaultHTTPLogMaxBodySize,
},
{
name: "negative max body size returns default",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
HTTPLog: &HTTPLogSpec{MaxBodySize: -100},
},
expected: DefaultHTTPLogMaxBodySize,
},
{
name: "custom max body size",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
HTTPLog: &HTTPLogSpec{MaxBodySize: 2048},
},
expected: 2048,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.forward.GetHTTPLogMaxBodySize()
assert.Equal(t, tt.expected, result)
})
}
}
// TestForward_GetMDNSAlias tests mDNS alias generation
func TestForward_GetMDNSAlias(t *testing.T) {
tests := []struct {
name string
forward Forward
expected string
}{
{
name: "explicit alias",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
Alias: "my-custom-alias",
},
expected: "my-custom-alias",
},
{
name: "pod with name - extracts name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
},
expected: "my-app",
},
{
name: "service with name - extracts name",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5432,
},
expected: "postgres",
},
{
name: "pod without name (selector-based) - returns empty",
forward: Forward{
Resource: "pod",
Selector: "app=nginx",
Port: 80,
LocalPort: 8080,
},
expected: "",
},
{
name: "empty resource - returns empty",
forward: Forward{
Resource: "",
Port: 8080,
LocalPort: 8080,
},
expected: "",
},
{
name: "resource with empty name after slash",
forward: Forward{
Resource: "pod/",
Port: 8080,
LocalPort: 8080,
},
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.forward.GetMDNSAlias()
assert.Equal(t, tt.expected, result)
})
}
}
// TestLoadConfig_FileTooLarge tests file size limit
func TestLoadConfig_FileTooLarge(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create a file larger than maxConfigSize (10MB)
// We'll use a smaller buffer to avoid memory issues
// Just verify the check happens by creating a file slightly over 10MB
largeData := make([]byte, 10*1024*1024+1) // 10MB + 1 byte
for i := range largeData {
largeData[i] = 'a'
}
err := os.WriteFile(configPath, largeData, 0644)
require.NoError(t, err)
cfg, err := LoadConfig(configPath)
assert.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "config file too large")
}
// TestLoadConfig_WithHealthCheckAndReliability tests parsing with all config sections
func TestLoadConfig_WithHealthCheckAndReliability(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
yaml := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
healthCheck:
interval: "5s"
timeout: "1s"
method: "tcp-dial"
maxConnectionAge: "20m"
maxIdleTime: "5m"
reliability:
tcpKeepalive: "15s"
dialTimeout: "10s"
retryOnStale: true
watchdogPeriod: "1m"
mdns:
enabled: true
`
err := os.WriteFile(configPath, []byte(yaml), 0644)
require.NoError(t, err)
cfg, err := LoadConfig(configPath)
require.NoError(t, err)
require.NotNil(t, cfg)
// Verify health check settings
assert.Equal(t, 5*time.Second, cfg.GetHealthCheckIntervalOrDefault())
assert.Equal(t, 1*time.Second, cfg.GetHealthCheckTimeoutOrDefault())
assert.Equal(t, "tcp-dial", cfg.GetHealthCheckMethod())
assert.Equal(t, 20*time.Minute, cfg.GetMaxConnectionAge())
assert.Equal(t, 5*time.Minute, cfg.GetMaxIdleTime())
// Verify reliability settings
assert.Equal(t, 15*time.Second, cfg.GetTCPKeepalive())
assert.Equal(t, 10*time.Second, cfg.GetDialTimeout())
assert.True(t, cfg.GetRetryOnStale())
assert.Equal(t, 1*time.Minute, cfg.GetWatchdogPeriod())
// Verify mDNS
assert.True(t, cfg.IsMDNSEnabled())
}
// TestParseConfig_RejectsUnknownKeys tests strict parsing
func TestParseConfig_RejectsUnknownKeys(t *testing.T) {
yaml := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
unknownKey: value
`
cfg, err := ParseConfig([]byte(yaml))
assert.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "failed to parse YAML")
}
// TestHTTPLogSpec_FullStruct tests full HTTPLogSpec parsing
func TestHTTPLogSpec_FullStruct(t *testing.T) {
yaml := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: service/api
port: 8080
localPort: 8080
httpLog:
enabled: true
logFile: "/tmp/http.log"
maxBodySize: 2048
includeHeaders: true
filterPath: "/api/*"
`
cfg, err := ParseConfig([]byte(yaml))
require.NoError(t, err)
require.NotNil(t, cfg)
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
require.NotNil(t, fwd.HTTPLog)
assert.True(t, fwd.HTTPLog.Enabled)
assert.Equal(t, "/tmp/http.log", fwd.HTTPLog.LogFile)
assert.Equal(t, 2048, fwd.HTTPLog.MaxBodySize)
assert.True(t, fwd.HTTPLog.IncludeHeaders)
assert.Equal(t, "/api/*", fwd.HTTPLog.FilterPath)
}