Files
kportal/internal/config/validator_test.go
T
4d263319ab feat: add --context to forward only selected contexts (#85)
* feat: add --context to forward only selected contexts

kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.

--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:

    kportal --context development-team-a
    kportal --context team-a,team-b -headless
    kportal --context team-a --context team-b

An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.

Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.

Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.

Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.

* fix(config): keep the running config when no selected context remains

A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.

LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.

Refs #80

* fix(ui): apply new table columns after a successful SIGHUP reload

The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.

Refs #80

* style: gofmt run_test.go

---------

Co-authored-by: André Cruz <[email protected]>
2026-10-02 02:33:29 +01:00

2360 lines
63 KiB
Go

package config
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidator_ValidateConfig(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
errorContains []string
expectErrors bool
}{
{
name: "valid config",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "nil config",
config: nil,
expectErrors: true,
errorContains: []string{"Configuration is nil"},
},
{
name: "empty contexts",
config: &Config{
Contexts: []Context{},
},
expectErrors: true,
errorContains: []string{"must have at least one context"},
},
{
name: "empty namespaces",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{},
},
},
},
expectErrors: true,
errorContains: []string{"must have at least one namespace"},
},
{
name: "empty forwards",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"must have at least one forward"},
},
{
name: "invalid port - zero",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 0,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid port 0"},
},
{
name: "invalid port - above max",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 65536,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid localPort 65536"},
},
{
name: "invalid protocol",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "http",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid protocol 'http'", "only 'tcp' is supported"},
},
{
name: "empty resource",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Resource cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_ValidateResourceFormat(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
errorContains []string
forward Forward
expectErrors bool
}{
{
name: "valid pod with name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid service with name",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid pod with selector (no name)",
forward: Forward{
Resource: "pod",
Selector: "app=nginx",
Port: 80,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "invalid resource type",
forward: Forward{
Resource: "deployment/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Invalid resource type 'deployment'"},
},
{
name: "pod with name and selector (invalid)",
forward: Forward{
Resource: "pod/my-app",
Selector: "app=nginx",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"should not have a selector"},
},
{
name: "pod without name and without selector (invalid)",
forward: Forward{
Resource: "pod",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"must have a selector"},
},
{
name: "service without name (invalid)",
forward: Forward{
Resource: "service",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Service name cannot be empty"},
},
{
name: "service with selector (invalid)",
forward: Forward{
Resource: "service/postgres",
Selector: "app=db",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"should not have a selector"},
},
{
name: "pod with empty name after slash",
forward: Forward{
Resource: "pod/",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Pod name cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateForward(&tt.forward)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_CheckDuplicatePorts(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
errorContains []string
expectErrors bool
}{
{
name: "no duplicate ports",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
{
Resource: "pod/app2",
Port: 8081,
LocalPort: 8081,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "duplicate ports in same namespace",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
{
Resource: "pod/app2",
Port: 8081,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
{
name: "duplicate ports across namespaces",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "ns1",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "ns1",
},
},
},
{
Name: "ns2",
Forwards: []Forward{
{
Resource: "pod/app2",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "ns2",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
{
name: "duplicate ports across contexts",
config: &Config{
Contexts: []Context{
{
Name: "cluster1",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "cluster1",
namespaceName: "default",
},
},
},
},
},
{
Name: "cluster2",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app2",
Port: 8080,
LocalPort: 8080,
contextName: "cluster2",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateDuplicatePorts(tt.config, nil)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestFormatValidationErrors(t *testing.T) {
tests := []struct {
name string
errors []ValidationError
expectContains []string
expectEmpty bool
}{
{
name: "no errors",
errors: []ValidationError{},
expectEmpty: true,
},
{
name: "single error",
errors: []ValidationError{
{
Field: "port",
Message: "Invalid port 0",
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "1. Invalid port 0"},
},
{
name: "multiple errors",
errors: []ValidationError{
{
Field: "port",
Message: "Invalid port 0",
},
{
Field: "resource",
Message: "Resource cannot be empty",
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "1. Invalid port 0", "2. Resource cannot be empty"},
},
{
name: "error with context",
errors: []ValidationError{
{
Field: "localPort",
Message: "Duplicate local port 8080",
Context: map[string]string{
"port": "8080",
"forwards": "dev/default/pod/app1:8080, dev/default/pod/app2:8080",
},
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "Duplicate local port 8080", "port:", "8080", "forwards:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := FormatValidationErrors(tt.errors)
if tt.expectEmpty {
assert.Empty(t, output, "expected empty output")
} else {
assert.NotEmpty(t, output, "expected non-empty output")
// Check that expected strings are present
for _, expected := range tt.expectContains {
assert.Contains(t, output, expected, "output should contain '%s'", expected)
}
}
})
}
}
func TestValidator_ValidateStructure(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
errorContains []string
expectErrors bool
}{
{
name: "empty context name",
config: &Config{
Contexts: []Context{
{
Name: "",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Context name cannot be empty"},
},
{
name: "empty namespace name",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Namespace name cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateStructure(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_ValidateMDNS(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
errorContains []string
expectErrors bool
}{
{
name: "mDNS disabled - no validation",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "invalid_alias", contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "mDNS enabled - valid aliases",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "my-app", contextName: "dev", namespaceName: "default"},
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "my-service", contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "mDNS enabled - no alias (allowed)",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "mDNS enabled - invalid alias with underscore",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "my_app", contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"invalid mDNS hostname", "RFC 1123"},
},
{
name: "mDNS enabled - alias starts with hyphen",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "-myapp", contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"invalid mDNS hostname"},
},
{
name: "mDNS enabled - alias ends with hyphen",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "myapp-", contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"invalid mDNS hostname"},
},
{
name: "mDNS enabled - duplicate aliases",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "myapp", contextName: "dev", namespaceName: "default"},
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "myapp", contextName: "dev", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate mDNS hostname", "conflict"},
},
{
name: "mDNS enabled - duplicate aliases across contexts",
config: &Config{
MDNS: &MDNSSpec{Enabled: true},
Contexts: []Context{
{
Name: "cluster1",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "shared-name", contextName: "cluster1", namespaceName: "default"},
},
},
},
},
{
Name: "cluster2",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "shared-name", contextName: "cluster2", namespaceName: "default"},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate mDNS hostname", "shared-name"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
}
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestIsValidHostname(t *testing.T) {
tests := []struct {
name string
hostname string
valid bool
}{
{name: "valid simple", hostname: "myservice", valid: true},
{name: "valid with hyphen", hostname: "my-service", valid: true},
{name: "valid with numbers", hostname: "service123", valid: true},
{name: "valid mixed", hostname: "my-service-123", valid: true},
{name: "valid uppercase", hostname: "MyService", valid: true},
{name: "valid single char", hostname: "a", valid: true},
{name: "valid single digit", hostname: "1", valid: true},
{name: "valid max length (63)", hostname: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true},
{name: "invalid empty", hostname: "", valid: false},
{name: "invalid too long (64)", hostname: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: false},
{name: "invalid starts with hyphen", hostname: "-myservice", valid: false},
{name: "invalid ends with hyphen", hostname: "myservice-", valid: false},
{name: "invalid underscore", hostname: "my_service", valid: false},
{name: "invalid dot", hostname: "my.service", valid: false},
{name: "invalid space", hostname: "my service", valid: false},
{name: "invalid special char", hostname: "my@service", valid: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidHostname(tt.hostname)
assert.Equal(t, tt.valid, result, "isValidHostname(%q) = %v, want %v", tt.hostname, result, tt.valid)
})
}
}
func TestIsAlphanumeric(t *testing.T) {
tests := []struct {
char byte
valid bool
}{
{char: 'a', valid: true},
{char: 'z', valid: true},
{char: 'A', valid: true},
{char: 'Z', valid: true},
{char: '0', valid: true},
{char: '9', valid: true},
{char: '-', valid: false},
{char: '_', valid: false},
{char: '.', valid: false},
{char: ' ', valid: false},
{char: '@', valid: false},
}
for _, tt := range tests {
t.Run(string(tt.char), func(t *testing.T) {
result := isAlphanumeric(tt.char)
assert.Equal(t, tt.valid, result, "isAlphanumeric(%q) = %v, want %v", tt.char, result, tt.valid)
})
}
}
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
allowEmpty bool
expectErrors bool
}{
{
name: "empty config - strict mode",
config: &Config{Contexts: []Context{}},
allowEmpty: false,
expectErrors: true,
},
{
name: "empty config - allow empty",
config: &Config{Contexts: []Context{}},
allowEmpty: true,
expectErrors: false,
},
{
name: "nil contexts - allow empty",
config: &Config{},
allowEmpty: true,
expectErrors: false,
},
{
name: "context with no forwards - allow empty",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{Name: "default", Forwards: []Forward{}},
},
},
},
},
allowEmpty: true,
expectErrors: false,
},
{
name: "valid config - strict mode",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: false,
expectErrors: false,
},
{
name: "valid config - allow empty (should still validate)",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: true,
expectErrors: false,
},
{
name: "invalid forward in non-empty config - allow empty still validates",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 0, // Invalid port
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: true,
expectErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestValidatePort(t *testing.T) {
tests := []struct {
name string
portName string
port int
expectError bool
}{
{name: "valid port - minimum", portName: "port", port: 1, expectError: false},
{name: "valid port - maximum", portName: "port", port: 65535, expectError: false},
{name: "valid port - middle", portName: "port", port: 8080, expectError: false},
{name: "invalid port - zero", portName: "port", port: 0, expectError: true},
{name: "invalid port - negative", portName: "port", port: -1, expectError: true},
{name: "invalid port - too high", portName: "port", port: 65536, expectError: true},
{name: "invalid port - very high", portName: "localPort", port: 100000, expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePort(tt.port, tt.portName)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.portName)
assert.Contains(t, err.Error(), fmt.Sprintf("%d", tt.port))
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateResourceFormat(t *testing.T) {
tests := []struct {
name string
resource string
errorMsg string
expectError bool
}{
{name: "valid pod", resource: "pod/my-app", errorMsg: "", expectError: false},
{name: "valid service", resource: "service/my-service", errorMsg: "", expectError: false},
{name: "valid pod with subdomain", resource: "pod/my-app.example.com", errorMsg: "", expectError: false},
{name: "missing slash", resource: "pod", errorMsg: "must be in format 'type/name'", expectError: true},
{name: "empty string", resource: "", errorMsg: "must be in format 'type/name'", expectError: true},
{name: "invalid type", resource: "deployment/my-app", errorMsg: "invalid resource type", expectError: true},
{name: "empty name", resource: "pod/", errorMsg: "resource name cannot be empty", expectError: true},
{name: "multiple slashes", resource: "pod/name/extra", errorMsg: "", expectError: false}, // First slash separates type/name, rest is part of name
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateResourceFormat(tt.resource)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateDuration(t *testing.T) {
tests := []struct {
name string
duration string
durationName string
expectError bool
}{
{name: "valid seconds", duration: "10s", durationName: "interval", expectError: false},
{name: "valid minutes", duration: "5m", durationName: "timeout", expectError: false},
{name: "valid hours", duration: "1h", durationName: "maxAge", expectError: false},
{name: "valid milliseconds", duration: "500ms", durationName: "timeout", expectError: false},
{name: "valid complex", duration: "1h30m", durationName: "duration", expectError: false},
{name: "empty string", duration: "", durationName: "interval", expectError: false}, // Empty is allowed (uses default)
{name: "invalid - no unit", duration: "10", durationName: "interval", expectError: true},
{name: "invalid - bad format", duration: "abc", durationName: "timeout", expectError: true},
{name: "invalid - unknown unit", duration: "10x", durationName: "interval", expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateDuration(tt.duration, tt.durationName)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.durationName)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateDNS1123Label(t *testing.T) {
tests := []struct {
name string
label string
errorMsg string
expectError bool
}{
{name: "valid simple", label: "myname", errorMsg: "", expectError: false},
{name: "valid with hyphen", label: "my-name", errorMsg: "", expectError: false},
{name: "valid with numbers", label: "name123", errorMsg: "", expectError: false},
{name: "valid single char", label: "a", errorMsg: "", expectError: false},
{name: "valid max length", label: strings.Repeat("a", 63), errorMsg: "", expectError: false},
{name: "invalid empty", label: "", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid uppercase", label: "MyName", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid underscore", label: "my_name", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid dot", label: "my.name", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid starts with hyphen", label: "-name", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid ends with hyphen", label: "name-", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid too long", label: strings.Repeat("a", 64), errorMsg: "exceeds maximum length", expectError: true},
{name: "invalid space", label: "my name", errorMsg: "not a valid DNS label", expectError: true},
{name: "invalid special char", label: "name@", errorMsg: "not a valid DNS label", expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDNS1123Label(tt.label, "test.field", "Test")
if tt.expectError {
assert.NotNil(t, err)
assert.Contains(t, err.Message, tt.errorMsg)
} else {
assert.Nil(t, err)
}
})
}
}
func TestValidateDNS1123Subdomain(t *testing.T) {
tests := []struct {
name string
subdomain string
errorMsg string
expectError bool
}{
{name: "valid simple", subdomain: "myname", errorMsg: "", expectError: false},
{name: "valid with hyphen", subdomain: "my-name", errorMsg: "", expectError: false},
{name: "valid with dot", subdomain: "my.name", errorMsg: "", expectError: false},
{name: "valid subdomain", subdomain: "app.example.com", errorMsg: "", expectError: false},
{name: "valid with numbers", subdomain: "app123.example456", errorMsg: "", expectError: false},
{name: "valid single char", subdomain: "a", errorMsg: "", expectError: false},
{name: "valid max length", subdomain: strings.Repeat("a", 253), errorMsg: "", expectError: false},
{name: "invalid empty", subdomain: "", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid uppercase", subdomain: "My.Name", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid underscore", subdomain: "my_name", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid starts with dot", subdomain: ".name", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid ends with dot", subdomain: "name.", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid double dot", subdomain: "my..name", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid starts with hyphen", subdomain: "-name", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid ends with hyphen", subdomain: "name-", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid too long", subdomain: strings.Repeat("a", 254), errorMsg: "exceeds maximum length", expectError: true},
{name: "invalid space", subdomain: "my name", errorMsg: "not a valid DNS subdomain", expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDNS1123Subdomain(tt.subdomain, "test.field", "Test")
if tt.expectError {
assert.NotNil(t, err)
assert.Contains(t, err.Message, tt.errorMsg)
} else {
assert.Nil(t, err)
}
})
}
}
func TestValidator_ValidateSpecDurations(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
errorContains []string
expectErrors bool
}{
{
name: "valid durations",
config: &Config{
HealthCheck: &HealthCheckSpec{
Interval: "5s",
Timeout: "2s",
MaxConnectionAge: "25m",
MaxIdleTime: "10m",
Method: "tcp-dial",
},
Reliability: &ReliabilitySpec{
TCPKeepalive: "30s",
DialTimeout: "30s",
WatchdogPeriod: "30s",
},
},
expectErrors: false,
},
{
name: "invalid health check interval",
config: &Config{
HealthCheck: &HealthCheckSpec{
Interval: "invalid",
},
},
expectErrors: true,
errorContains: []string{"Invalid health check interval"},
},
{
name: "invalid health check timeout",
config: &Config{
HealthCheck: &HealthCheckSpec{
Timeout: "abc",
},
},
expectErrors: true,
errorContains: []string{"Invalid health check timeout"},
},
{
name: "invalid max connection age",
config: &Config{
HealthCheck: &HealthCheckSpec{
MaxConnectionAge: "xyz",
},
},
expectErrors: true,
errorContains: []string{"Invalid max connection age"},
},
{
name: "invalid max idle time",
config: &Config{
HealthCheck: &HealthCheckSpec{
MaxIdleTime: "bad",
},
},
expectErrors: true,
errorContains: []string{"Invalid max idle time"},
},
{
name: "invalid health check method",
config: &Config{
HealthCheck: &HealthCheckSpec{
Method: "invalid-method",
},
},
expectErrors: true,
errorContains: []string{"Invalid health check method"},
},
{
name: "invalid TCP keepalive",
config: &Config{
Reliability: &ReliabilitySpec{
TCPKeepalive: "not-a-duration",
},
},
expectErrors: true,
errorContains: []string{"Invalid TCP keepalive duration"},
},
{
name: "invalid dial timeout",
config: &Config{
Reliability: &ReliabilitySpec{
DialTimeout: "bad",
},
},
expectErrors: true,
errorContains: []string{"Invalid dial timeout"},
},
{
name: "invalid watchdog period",
config: &Config{
Reliability: &ReliabilitySpec{
WatchdogPeriod: "invalid",
},
},
expectErrors: true,
errorContains: []string{"Invalid watchdog period"},
},
{
name: "multiple invalid durations",
config: &Config{
HealthCheck: &HealthCheckSpec{
Interval: "bad",
Timeout: "worse",
},
},
expectErrors: true,
errorContains: []string{"Invalid health check interval", "Invalid health check timeout"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateSpecDurations(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
}
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestValidator_ValidateContextAndNamespaceNames(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
errorContains []string
expectErrors bool
}{
{
name: "valid context and namespace names",
config: &Config{
Contexts: []Context{
{
Name: "my-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: false,
},
{
name: "valid context with underscores (kubeconfig style)",
config: &Config{
Contexts: []Context{
{
Name: "gke_project_zone_cluster",
Namespaces: []Namespace{
{
Name: "my-namespace",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: false, // Context names now allow underscores
},
{
name: "valid context with uppercase",
config: &Config{
Contexts: []Context{
{
Name: "MyCluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: false, // Context names now allow uppercase
},
{
name: "valid namespace with dots (subdomain style)",
config: &Config{
Contexts: []Context{
{
Name: "my-cluster",
Namespaces: []Namespace{
{
Name: "my.app.example",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: false, // Namespaces now allow dots (DNS subdomain format)
},
{
name: "invalid namespace name with uppercase",
config: &Config{
Contexts: []Context{
{
Name: "my-cluster",
Namespaces: []Namespace{
{
Name: "MyNamespace",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"not a valid DNS subdomain"},
},
{
name: "invalid context name with spaces",
config: &Config{
Contexts: []Context{
{
Name: "my cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"not valid", "letter or digit"},
},
{
name: "context name too long",
config: &Config{
Contexts: []Context{
{
Name: strings.Repeat("a", 254),
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"exceeds maximum length"},
},
{
name: "invalid context name starts with hyphen",
config: &Config{
Contexts: []Context{
{
Name: "-mycluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"not valid", "letter or digit"},
},
{
name: "invalid context name ends with underscore",
config: &Config{
Contexts: []Context{
{
Name: "mycluster_",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"not valid", "letter or digit"},
},
{
name: "invalid namespace name with spaces",
config: &Config{
Contexts: []Context{
{
Name: "my-cluster",
Namespaces: []Namespace{
{
Name: "my namespace",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"not a valid DNS subdomain"},
},
{
name: "invalid namespace name with underscore",
config: &Config{
Contexts: []Context{
{
Name: "my-cluster",
Namespaces: []Namespace{
{
Name: "my_namespace",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"not a valid DNS subdomain"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
}
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestValidator_ValidateResourceNames(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
errorContains []string
forward Forward
expectErrors bool
}{
{
name: "valid resource name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid resource name with subdomain",
forward: Forward{
Resource: "service/my-service.example.com",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "invalid resource name with uppercase",
forward: Forward{
Resource: "pod/MyApp",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"not a valid DNS subdomain"},
},
{
name: "invalid resource name with underscore",
forward: Forward{
Resource: "pod/my_app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"not a valid DNS subdomain"},
},
{
name: "invalid resource name starts with hyphen",
forward: Forward{
Resource: "pod/-myapp",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"not a valid DNS subdomain"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateForward(&tt.forward)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
}
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestIsValidResourceType(t *testing.T) {
tests := []struct {
resourceType string
expected bool
}{
{resourceType: "pod", expected: true},
{resourceType: "service", expected: true},
{resourceType: "deployment", expected: false},
{resourceType: "configmap", expected: false},
{resourceType: "", expected: false},
{resourceType: "POD", expected: false}, // case sensitive
{resourceType: "Pod", expected: false}, // case sensitive
}
for _, tt := range tests {
t.Run(tt.resourceType, func(t *testing.T) {
result := isValidResourceType(tt.resourceType)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsValidHealthCheckMethod(t *testing.T) {
tests := []struct {
method string
expected bool
}{
{method: "tcp-dial", expected: true},
{method: "data-transfer", expected: true},
{method: "ping", expected: false},
{method: "http", expected: false},
{method: "", expected: false},
{method: "TCP-DIAL", expected: false}, // case sensitive
}
for _, tt := range tests {
t.Run(tt.method, func(t *testing.T) {
result := isValidHealthCheckMethod(tt.method)
assert.Equal(t, tt.expected, result)
})
}
}
func TestValidator_ValidateHTTPLog(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
errorContains []string
forward Forward
expectErrors bool
}{
{
name: "valid HTTP log config",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
HTTPLog: &HTTPLogSpec{
Enabled: true,
MaxBodySize: 1024,
LogFile: "/tmp/test.log",
},
},
expectErrors: false,
},
{
name: "HTTP log disabled - no validation needed",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
HTTPLog: &HTTPLogSpec{
Enabled: false,
MaxBodySize: -1, // Would be invalid if enabled
},
},
expectErrors: false,
},
{
name: "no HTTP log config",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "invalid negative maxBodySize",
forward: Forward{
Resource: "pod/app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
HTTPLog: &HTTPLogSpec{
Enabled: true,
MaxBodySize: -1,
},
},
expectErrors: true,
errorContains: []string{"maxBodySize", "non-negative"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateForward(&tt.forward)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
}
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestValidateContextName(t *testing.T) {
tests := []struct {
name string
contextName string
errorMsg string
expectError bool
}{
// Valid cases
{name: "valid simple", contextName: "mycluster", errorMsg: "", expectError: false},
{name: "valid with hyphen", contextName: "my-cluster", errorMsg: "", expectError: false},
{name: "valid with underscore", contextName: "my_cluster", errorMsg: "", expectError: false},
{name: "valid with numbers", contextName: "cluster123", errorMsg: "", expectError: false},
{name: "valid mixed", contextName: "my-cluster_123", errorMsg: "", expectError: false},
{name: "valid uppercase", contextName: "MyCluster", errorMsg: "", expectError: false},
{name: "valid mixed case", contextName: "myCluster-Test_123", errorMsg: "", expectError: false},
{name: "valid GKE style", contextName: "gke_project_us-central1_cluster", errorMsg: "", expectError: false},
{name: "valid minikube", contextName: "minikube", errorMsg: "", expectError: false},
{name: "valid docker desktop", contextName: "docker-desktop", errorMsg: "", expectError: false},
{name: "valid docker desktop alt", contextName: "docker_desktop", errorMsg: "", expectError: false},
{name: "valid single char", contextName: "a", errorMsg: "", expectError: false},
{name: "valid single digit", contextName: "1", errorMsg: "", expectError: false},
{name: "valid starts with digit", contextName: "123-cluster", errorMsg: "", expectError: false},
{name: "valid user@cluster", contextName: "admin@home", errorMsg: "", expectError: false},
{name: "valid user@fqdn", contextName: "[email protected]", errorMsg: "", expectError: false},
{name: "valid dotted FQDN", contextName: "cluster.example.com", errorMsg: "", expectError: false},
{name: "valid GKE dotted", contextName: "gke_proj_zone_cluster.prod", errorMsg: "", expectError: false},
{name: "valid EKS ARN", contextName: "arn:aws:eks:us-east-1:123:cluster/foo", errorMsg: "", expectError: false},
// Invalid cases
{name: "invalid empty", contextName: "", errorMsg: "not valid", expectError: true},
{name: "invalid starts with hyphen", contextName: "-cluster", errorMsg: "not valid", expectError: true},
{name: "invalid ends with hyphen", contextName: "cluster-", errorMsg: "not valid", expectError: true},
{name: "invalid starts with underscore", contextName: "_cluster", errorMsg: "not valid", expectError: true},
{name: "invalid ends with underscore", contextName: "cluster_", errorMsg: "not valid", expectError: true},
{name: "invalid starts with @", contextName: "@cluster", errorMsg: "not valid", expectError: true},
{name: "invalid ends with /", contextName: "cluster/", errorMsg: "not valid", expectError: true},
{name: "invalid ends with .", contextName: "cluster.", errorMsg: "not valid", expectError: true},
{name: "invalid with spaces", contextName: "my cluster", errorMsg: "not valid", expectError: true},
{name: "invalid too long", contextName: strings.Repeat("a", 254), errorMsg: "exceeds maximum length", expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateContextName(tt.contextName, "test.field")
if tt.expectError {
assert.NotNil(t, err)
assert.Contains(t, err.Message, tt.errorMsg)
} else {
assert.Nil(t, err)
}
})
}
}
func TestValidateNamespaceName(t *testing.T) {
tests := []struct {
name string
namespace string
errorMsg string
expectError bool
}{
// Valid cases
{name: "valid simple", namespace: "default", errorMsg: "", expectError: false},
{name: "valid with hyphen", namespace: "kube-system", errorMsg: "", expectError: false},
{name: "valid with dots", namespace: "my.app.example", errorMsg: "", expectError: false},
{name: "valid subdomain", namespace: "app.ns.cluster.local", errorMsg: "", expectError: false},
{name: "valid with numbers", namespace: "ns123", errorMsg: "", expectError: false},
{name: "valid mixed", namespace: "my-app-123.test", errorMsg: "", expectError: false},
{name: "valid single char", namespace: "a", errorMsg: "", expectError: false},
{name: "valid single digit", namespace: "1", errorMsg: "", expectError: false},
{name: "valid starts with digit", namespace: "123-ns", errorMsg: "", expectError: false},
// Invalid cases
{name: "invalid empty", namespace: "", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid starts with hyphen", namespace: "-namespace", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid ends with hyphen", namespace: "namespace-", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid with underscore", namespace: "my_namespace", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid with spaces", namespace: "my namespace", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid starts with dot", namespace: ".namespace", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid ends with dot", namespace: "namespace.", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid double dot", namespace: "my..namespace", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid uppercase", namespace: "MyNamespace", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid with special chars", namespace: "ns@123", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid with slash", namespace: "ns/name", errorMsg: "not a valid DNS subdomain", expectError: true},
{name: "invalid too long", namespace: strings.Repeat("a", 254), errorMsg: "exceeds maximum length", expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateNamespaceName(tt.namespace, "test.field")
if tt.expectError {
assert.NotNil(t, err)
assert.Contains(t, err.Message, tt.errorMsg)
} else {
assert.Nil(t, err)
}
})
}
}
func TestIsValidPort(t *testing.T) {
tests := []struct {
name string
port int
expected bool
}{
// Valid ports
{name: "valid minimum", port: 1, expected: true},
{name: "valid maximum", port: 65535, expected: true},
{name: "valid common", port: 8080, expected: true},
{name: "valid HTTP", port: 80, expected: true},
{name: "valid HTTPS", port: 443, expected: true},
{name: "valid high", port: 30000, expected: true},
// Invalid ports
{name: "invalid zero", port: 0, expected: false},
{name: "invalid negative", port: -1, expected: false},
{name: "invalid too high", port: 65536, expected: false},
{name: "invalid very high", port: 100000, expected: false},
{name: "invalid negative large", port: -8080, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsValidPort(tt.port)
assert.Equal(t, tt.expected, result, "IsValidPort(%d) = %v, want %v", tt.port, result, tt.expected)
})
}
}
func TestValidateProtocol(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
protocol string
errorContains string
expectErrors bool
}{
// Valid protocols
{name: "valid tcp", protocol: "tcp", errorContains: "", expectErrors: false},
{name: "valid empty", protocol: "", errorContains: "", expectErrors: false},
// Invalid protocols
{name: "invalid udp", protocol: "udp", errorContains: "only 'tcp' is supported", expectErrors: true},
{name: "invalid http", protocol: "http", errorContains: "only 'tcp' is supported", expectErrors: true},
{name: "invalid https", protocol: "https", errorContains: "only 'tcp' is supported", expectErrors: true},
{name: "invalid uppercase TCP", protocol: "TCP", errorContains: "only 'tcp' is supported", expectErrors: true},
{name: "invalid mixed case", protocol: "Tcp", errorContains: "only 'tcp' is supported", expectErrors: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fwd := Forward{
Resource: "pod/my-app",
Protocol: tt.protocol,
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
}
errs := validator.validateForward(&fwd)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
found := false
for _, err := range errs {
if strings.Contains(err.Message, tt.errorContains) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", tt.errorContains, errs)
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
func TestValidator_ValidateTUI(t *testing.T) {
validator := NewValidator()
baseContexts := []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Protocol: "tcp", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
},
},
},
},
}
tests := []struct {
config *Config
name string
errorContains string
expectErrors bool
}{
{
name: "no tui config - no validation",
config: &Config{Contexts: baseContexts},
expectErrors: false,
},
{
name: "valid columns",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{
Columns: []TableColumn{
{Name: "alias", Width: 30},
{Name: "status"},
},
},
},
expectErrors: false,
},
{
name: "unknown column name",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "bogus"}}},
},
expectErrors: true,
errorContains: "Invalid column name",
},
{
name: "empty column name",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: ""}}},
},
expectErrors: true,
errorContains: "cannot be empty",
},
{
name: "duplicate column",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias"}, {Name: "Alias"}}},
},
expectErrors: true,
errorContains: "Duplicate column",
},
{
name: "negative width",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: -1}}},
},
expectErrors: true,
errorContains: "cannot be negative",
},
{
name: "width at cap",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: 200}}},
},
expectErrors: false,
},
{
name: "width above cap",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: 201}}},
},
expectErrors: true,
errorContains: "cannot exceed 200",
},
{
name: "name is case and whitespace insensitive",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: " Alias "}, {Name: "STATUS"}}},
},
expectErrors: false,
},
{
name: "padded duplicate is detected",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias"}, {Name: " Alias "}}},
},
expectErrors: true,
errorContains: "Duplicate column",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs)
found := false
for _, err := range errs {
if strings.Contains(err.Message, tt.errorContains) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", tt.errorContains, errs)
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
// TestValidator_DuplicatePortsScopedToActiveContexts covers the rule that makes
// --context useful: forwards in contexts that are never running at the same
// time may reuse a local port, while forwards that would genuinely collide are
// still rejected.
func TestValidator_DuplicatePortsScopedToActiveContexts(t *testing.T) {
validator := NewValidator()
buildConfig := func() *Config {
cfg, err := ParseConfig([]byte(`contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/rpc-server
protocol: tcp
port: 50051
localPort: 3004
- name: team-b
namespaces:
- name: default
forwards:
- resource: pod/rpc-server
protocol: tcp
port: 50051
localPort: 3004
`))
if err != nil {
t.Fatalf("ParseConfig() failed: %v", err)
}
return cfg
}
tests := []struct {
name string
active []string
expectErrors bool
}{
{name: "no selection keeps the global check", active: nil, expectErrors: true},
{name: "one active context allows the shared port", active: []string{"team-a"}, expectErrors: false},
{name: "other active context allows it too", active: []string{"team-b"}, expectErrors: false},
{name: "both active reinstates the conflict", active: []string{"team-a", "team-b"}, expectErrors: true},
{name: "neither active leaves them isolated", active: []string{"team-c"}, expectErrors: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfigWithOpts(buildConfig(), ValidateOptions{ActiveContexts: tt.active})
var portErrs []ValidationError
for _, err := range errs {
if err.Field == "localPort" {
portErrs = append(portErrs, err)
}
}
if tt.expectErrors {
assert.NotEmpty(t, portErrs, "expected a duplicate local port error")
} else {
assert.Empty(t, portErrs, "shared port should be allowed: %v", portErrs)
}
})
}
}
// TestValidator_DuplicatePortsWithinContextAlwaysConflict guards the case a
// scope key must never hide: two forwards in the SAME context always collide.
func TestValidator_DuplicatePortsWithinContextAlwaysConflict(t *testing.T) {
cfg, err := ParseConfig([]byte(`contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/one
protocol: tcp
port: 80
localPort: 8080
- resource: pod/two
protocol: tcp
port: 81
localPort: 8080
`))
if err != nil {
t.Fatalf("ParseConfig() failed: %v", err)
}
for _, active := range [][]string{nil, {"team-a"}, {"team-b"}} {
errs := NewValidator().ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: active})
found := false
for _, e := range errs {
if e.Field == "localPort" {
found = true
}
}
assert.True(t, found, "same-context duplicate must conflict (active=%v)", active)
}
}
// TestValidator_StructuralErrorsNotHiddenBySelection proves --check did not get
// weaker: a problem in a context that is not being forwarded is still reported.
func TestValidator_StructuralErrorsNotHiddenBySelection(t *testing.T) {
cfg, err := ParseConfig([]byte(`contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/ok
protocol: tcp
port: 80
localPort: 8080
- name: team-b
namespaces:
- name: default
forwards:
- resource: ""
protocol: tcp
port: 80
localPort: 9090
`))
if err != nil {
t.Fatalf("ParseConfig() failed: %v", err)
}
errs := NewValidator().ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: []string{"team-a"}})
assert.NotEmpty(t, errs, "an invalid forward in a non-selected context must still be reported")
}
// TestValidator_DuplicateMDNSAliasScopedToActiveContexts mirrors the local-port
// rule for mDNS hostnames, which can only clash while forwards are running.
func TestValidator_DuplicateMDNSAliasScopedToActiveContexts(t *testing.T) {
buildConfig := func() *Config {
cfg, err := ParseConfig([]byte(`mdns:
enabled: true
contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: service/api
protocol: tcp
port: 80
localPort: 8080
alias: api
- name: team-b
namespaces:
- name: default
forwards:
- resource: service/api
protocol: tcp
port: 80
localPort: 8080
alias: api
`))
if err != nil {
t.Fatalf("ParseConfig() failed: %v", err)
}
return cfg
}
aliasErrs := func(active []string) []ValidationError {
var out []ValidationError
for _, e := range NewValidator().ValidateConfigWithOpts(buildConfig(), ValidateOptions{ActiveContexts: active}) {
if e.Field == "alias" {
out = append(out, e)
}
}
return out
}
assert.NotEmpty(t, aliasErrs(nil), "duplicate hostname must conflict when everything runs")
assert.Empty(t, aliasErrs([]string{"team-a"}), "duplicate hostname is fine when only one context runs")
assert.NotEmpty(t, aliasErrs([]string{"team-a", "team-b"}), "both active means a real hostname clash")
}