mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
* Allow configuring forwards-table columns, order, and width Adds an optional tui.columns section to .kportal.yaml so the visible columns, their order, and their widths can be customized instead of always rendering the hardcoded 8-column layout. Omitting the section keeps the existing default columns/order/widths. Closes #81 * fix(ui): keep the plain table layout and check column widths The plain verbose table must look the same when no tui section is set. It had narrower columns and shorter port headers than before. The default layout is now identical to the old one, and a golden test guards it. A status column that is not last now pads by visible width. Table rules follow the column widths. The UI applies new columns only after a reload succeeds, including the SIGHUP path of the verbose table. The validator rejects a column width above 200. The README describes the tui.columns setting. Refs #81 * fix(httplog): replace the deprecated ReverseProxy Director with Rewrite Staticcheck reports SA1019 because Go 1.26 deprecates Director. The proxy now sets the target in a Rewrite function. Rewrite drops the X-Forwarded-For header that Director sent, so the function calls SetXForwarded to keep that header. A new test checks the target port, the path and the header. * fix(release): drop the deprecated cask url verified key Homebrew warns about the verified parameter of the cask url stanza. The setting is not needed. GoReleaser check now passes without a deprecation warning. The postflight warning remains. GoReleaser generates it from the post install hook, and the upstream fix is not released yet. Refs #79 --------- Co-authored-by: Lukasz Raczylo <[email protected]>
2187 lines
58 KiB
Go
2187 lines
58 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)
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|