Major refactoring to hexagonal (ports & adapters) architecture: - Add service layer (apikey_service, project_service) for business logic - Add webhook system with dispatcher and delivery tracking - Add command queue with priority-based processing - Add rate limiting with sliding window algorithm - Add audit logging for command execution - Add OpenTelemetry integration (traces, metrics, spans) - Add circuit breaker for fault tolerance - Add cached repository wrapper for performance - Add comprehensive validation package - Add Kubernetes client integration for pod management - Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks) - Add network policy and PodDisruptionBudget for k8s - Remove legacy executor and projects/registry packages - Untrack secrets.yaml (now managed via envault) - Add coverage.out to .gitignore - Add e2e test infrastructure with docker-compose - Add comprehensive documentation (API, architecture, operations, plans) - Add golangci-lint config and pre-commit hook Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
549 lines
14 KiB
Go
549 lines
14 KiB
Go
package validate
|
|
|
|
import (
|
|
"regexp"
|
|
"testing"
|
|
)
|
|
|
|
func TestValidationError_Error(t *testing.T) {
|
|
err := ValidationError{Field: "name", Message: "is required"}
|
|
expected := "name: is required"
|
|
if err.Error() != expected {
|
|
t.Errorf("expected %q, got %q", expected, err.Error())
|
|
}
|
|
}
|
|
|
|
func TestValidationErrors_Error(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
errors ValidationErrors
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no errors",
|
|
errors: ValidationErrors{},
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "single error",
|
|
errors: ValidationErrors{
|
|
{Field: "name", Message: "is required"},
|
|
},
|
|
expected: "name: is required",
|
|
},
|
|
{
|
|
name: "multiple errors",
|
|
errors: ValidationErrors{
|
|
{Field: "name", Message: "is required"},
|
|
{Field: "email", Message: "is invalid"},
|
|
},
|
|
expected: "validation failed: name: is required; email: is invalid",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.errors.Error(); got != tt.expected {
|
|
t.Errorf("expected %q, got %q", tt.expected, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidationErrors_HasErrors(t *testing.T) {
|
|
if (ValidationErrors{}).HasErrors() {
|
|
t.Error("expected empty ValidationErrors to have no errors")
|
|
}
|
|
|
|
errs := ValidationErrors{{Field: "test", Message: "error"}}
|
|
if !errs.HasErrors() {
|
|
t.Error("expected non-empty ValidationErrors to have errors")
|
|
}
|
|
}
|
|
|
|
func TestValidationErrors_Fields(t *testing.T) {
|
|
errs := ValidationErrors{
|
|
{Field: "name", Message: "is required"},
|
|
{Field: "name", Message: "is too short"}, // Second error for same field
|
|
{Field: "email", Message: "is invalid"},
|
|
}
|
|
|
|
fields := errs.Fields()
|
|
|
|
if len(fields) != 2 {
|
|
t.Errorf("expected 2 fields, got %d", len(fields))
|
|
}
|
|
if fields["name"] != "is required" {
|
|
t.Errorf("expected name error to be 'is required', got %q", fields["name"])
|
|
}
|
|
if fields["email"] != "is invalid" {
|
|
t.Errorf("expected email error to be 'is invalid', got %q", fields["email"])
|
|
}
|
|
}
|
|
|
|
func TestValidator_Required(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
wantErr bool
|
|
}{
|
|
{"valid", "hello", false},
|
|
{"empty", "", true},
|
|
{"whitespace only", " ", true},
|
|
{"with whitespace", " hello ", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
v := New()
|
|
v.Required(tt.value, "field")
|
|
if (v.Error() != nil) != tt.wantErr {
|
|
t.Errorf("Required(%q) error = %v, wantErr %v", tt.value, v.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidator_RequiredSlice(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value []string
|
|
wantErr bool
|
|
}{
|
|
{"valid", []string{"a", "b"}, false},
|
|
{"single item", []string{"a"}, false},
|
|
{"empty", []string{}, true},
|
|
{"nil", nil, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
v := New()
|
|
v.RequiredSlice(tt.value, "field")
|
|
if (v.Error() != nil) != tt.wantErr {
|
|
t.Errorf("RequiredSlice(%v) error = %v, wantErr %v", tt.value, v.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidator_StringLength(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
min int
|
|
max int
|
|
wantErr bool
|
|
errMsg string
|
|
}{
|
|
{"valid", "hello", 1, 10, false, ""},
|
|
{"exact min", "ab", 2, 10, false, ""},
|
|
{"exact max", "hello", 1, 5, false, ""},
|
|
{"too short", "a", 2, 10, true, "must be at least 2 characters"},
|
|
{"too long", "hello world", 1, 5, true, "must be at most 5 characters"},
|
|
{"no min check", "", 0, 10, false, ""},
|
|
{"no max check", "very long string", 1, 0, false, ""},
|
|
{"empty with min", "", 1, 10, true, "must be at least 1 characters"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
v := New()
|
|
v.StringLength(tt.value, "field", tt.min, tt.max)
|
|
err := v.Error()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("StringLength(%q, %d, %d) error = %v, wantErr %v", tt.value, tt.min, tt.max, err, tt.wantErr)
|
|
}
|
|
if tt.wantErr && tt.errMsg != "" {
|
|
verr := v.errors[0]
|
|
if verr.Message != tt.errMsg {
|
|
t.Errorf("expected message %q, got %q", tt.errMsg, verr.Message)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidator_Pattern(t *testing.T) {
|
|
alphaOnly := regexp.MustCompile(`^[a-zA-Z]+$`)
|
|
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
wantErr bool
|
|
}{
|
|
{"valid", "hello", false},
|
|
{"empty (skipped)", "", false},
|
|
{"invalid", "hello123", true},
|
|
{"with spaces", "hello world", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
v := New()
|
|
v.Pattern(tt.value, "field", alphaOnly, "must contain only letters")
|
|
if (v.Error() != nil) != tt.wantErr {
|
|
t.Errorf("Pattern(%q) error = %v, wantErr %v", tt.value, v.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidator_Custom(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
valid bool
|
|
wantErr bool
|
|
}{
|
|
{"valid", true, false},
|
|
{"invalid", false, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
v := New()
|
|
v.Custom(tt.valid, "field", "custom error")
|
|
if (v.Error() != nil) != tt.wantErr {
|
|
t.Errorf("Custom(%v) error = %v, wantErr %v", tt.valid, v.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidator_AddError(t *testing.T) {
|
|
v := New()
|
|
v.AddError("field", "custom message")
|
|
|
|
if !v.HasErrors() {
|
|
t.Error("expected validator to have errors after AddError")
|
|
}
|
|
|
|
err := v.Error()
|
|
if err == nil {
|
|
t.Fatal("expected non-nil error")
|
|
}
|
|
|
|
verrs := err.(ValidationErrors)
|
|
if len(verrs) != 1 || verrs[0].Field != "field" || verrs[0].Message != "custom message" {
|
|
t.Errorf("unexpected error: %v", verrs)
|
|
}
|
|
}
|
|
|
|
func TestValidator_Composable(t *testing.T) {
|
|
// Test chaining multiple validations
|
|
v := New()
|
|
v.Required("test", "name").
|
|
StringLength("test", "name", 1, 64).
|
|
Pattern("test", "name", AlphanumericDashUnderscore, "must be alphanumeric")
|
|
|
|
if v.HasErrors() {
|
|
t.Errorf("expected no errors, got %v", v.Error())
|
|
}
|
|
|
|
// Test with failures
|
|
v2 := New()
|
|
v2.Required("", "name").
|
|
Required("", "email").
|
|
StringLength("verylongnamethatshouldexceedlimit", "description", 0, 10)
|
|
|
|
if !v2.HasErrors() {
|
|
t.Error("expected validation errors")
|
|
}
|
|
|
|
errs := v2.Errors()
|
|
if len(errs) != 3 {
|
|
t.Errorf("expected 3 errors, got %d", len(errs))
|
|
}
|
|
}
|
|
|
|
func TestValidator_Errors(t *testing.T) {
|
|
v := New()
|
|
v.Required("", "field1")
|
|
v.Required("", "field2")
|
|
|
|
errs := v.Errors()
|
|
if len(errs) != 2 {
|
|
t.Errorf("expected 2 errors, got %d", len(errs))
|
|
}
|
|
}
|
|
|
|
// --- Standalone function tests ---
|
|
|
|
func TestRequired(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
wantErr bool
|
|
}{
|
|
{"valid", "hello", false},
|
|
{"empty", "", true},
|
|
{"whitespace", " ", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := Required(tt.value, "field")
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Required(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRequiredSlice(t *testing.T) {
|
|
if err := RequiredSlice([]string{"a"}, "field"); err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
if err := RequiredSlice([]string{}, "field"); err == nil {
|
|
t.Error("expected error for empty slice")
|
|
}
|
|
if err := RequiredSlice(nil, "field"); err == nil {
|
|
t.Error("expected error for nil slice")
|
|
}
|
|
}
|
|
|
|
func TestStringLength(t *testing.T) {
|
|
if err := StringLength("hello", "field", 1, 10); err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
if err := StringLength("a", "field", 2, 10); err == nil {
|
|
t.Error("expected error for too short string")
|
|
}
|
|
if err := StringLength("hello world", "field", 1, 5); err == nil {
|
|
t.Error("expected error for too long string")
|
|
}
|
|
}
|
|
|
|
func TestPattern(t *testing.T) {
|
|
alphaOnly := regexp.MustCompile(`^[a-zA-Z]+$`)
|
|
|
|
if err := Pattern("hello", "field", alphaOnly, "must be alpha"); err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
if err := Pattern("hello123", "field", alphaOnly, "must be alpha"); err == nil {
|
|
t.Error("expected error for invalid pattern")
|
|
}
|
|
// Empty string should pass (use Required for that check)
|
|
if err := Pattern("", "field", alphaOnly, "must be alpha"); err != nil {
|
|
t.Errorf("expected no error for empty string, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Common patterns tests ---
|
|
|
|
func TestCommonPatterns(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern *regexp.Regexp
|
|
valid []string
|
|
invalid []string
|
|
}{
|
|
{
|
|
name: "AlphanumericDashUnderscore",
|
|
pattern: AlphanumericDashUnderscore,
|
|
valid: []string{"hello", "hello-world", "hello_world", "HelloWorld123", "a", "A1"},
|
|
invalid: []string{"hello world", "hello.world", "@test", ""},
|
|
},
|
|
{
|
|
name: "AlphanumericDashUnderscoreDot",
|
|
pattern: AlphanumericDashUnderscoreDot,
|
|
valid: []string{"hello", "hello.world", "config.yaml", "test-file_v1.2"},
|
|
invalid: []string{"hello world", "@test", "path/to/file"},
|
|
},
|
|
{
|
|
name: "Email",
|
|
pattern: Email,
|
|
valid: []string{"test@example.com", "user.name@domain.org", "a@b.co"},
|
|
invalid: []string{"@example.com", "test@", "test@.com", "test"},
|
|
},
|
|
{
|
|
name: "UUID",
|
|
pattern: UUID,
|
|
valid: []string{"550e8400-e29b-41d4-a716-446655440000", "ABCD1234-EF56-7890-ABCD-EF1234567890"},
|
|
invalid: []string{"550e8400", "not-a-uuid", "550e8400-e29b-41d4-a716-44665544000"},
|
|
},
|
|
{
|
|
name: "Slug",
|
|
pattern: Slug,
|
|
valid: []string{"hello", "hello-world", "my-blog-post", "a1b2"},
|
|
invalid: []string{"Hello", "hello_world", "hello--world", "-hello", "hello-"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
for _, v := range tt.valid {
|
|
if !tt.pattern.MatchString(v) {
|
|
t.Errorf("%s: expected %q to match", tt.name, v)
|
|
}
|
|
}
|
|
for _, v := range tt.invalid {
|
|
if tt.pattern.MatchString(v) {
|
|
t.Errorf("%s: expected %q to not match", tt.name, v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
wantErr bool
|
|
}{
|
|
{"valid", "my-config", false},
|
|
{"valid with underscore", "my_config", false},
|
|
{"valid alphanumeric", "config123", false},
|
|
{"empty", "", true},
|
|
{"too long", "this-name-is-way-too-long-and-exceeds-the-sixty-four-character-limit-allowed", true},
|
|
{"invalid chars", "my config", true},
|
|
{"dots not allowed", "my.config", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := Name(tt.value, "name")
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Name(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsValidationError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expected bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"ValidationError", ValidationError{Field: "f", Message: "m"}, true},
|
|
{"ValidationErrors", ValidationErrors{{Field: "f", Message: "m"}}, true},
|
|
{"other error", errOther, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := IsValidationError(tt.err); got != tt.expected {
|
|
t.Errorf("IsValidationError(%v) = %v, want %v", tt.err, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Custom error type for testing
|
|
type otherError struct{}
|
|
|
|
func (e otherError) Error() string { return "other error" }
|
|
|
|
var errOther error = otherError{}
|
|
|
|
func TestAsValidationErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expectNil bool
|
|
expectCount int
|
|
}{
|
|
{"nil", nil, true, 0},
|
|
{"ValidationError", ValidationError{Field: "f", Message: "m"}, false, 1},
|
|
{"ValidationErrors", ValidationErrors{{Field: "f1", Message: "m1"}, {Field: "f2", Message: "m2"}}, false, 2},
|
|
{"other error", errOther, true, 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := AsValidationErrors(tt.err)
|
|
if tt.expectNil {
|
|
if got != nil {
|
|
t.Errorf("expected nil, got %v", got)
|
|
}
|
|
} else {
|
|
if got == nil {
|
|
t.Error("expected non-nil ValidationErrors")
|
|
} else if len(got) != tt.expectCount {
|
|
t.Errorf("expected %d errors, got %d", tt.expectCount, len(got))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Integration-style tests ---
|
|
|
|
func TestValidator_RealWorldUsage(t *testing.T) {
|
|
// Simulating the CreateKeyRequest validation from keys.go
|
|
type CreateKeyRequest struct {
|
|
Name string
|
|
Scopes []string
|
|
}
|
|
|
|
validate := func(req CreateKeyRequest) error {
|
|
v := New()
|
|
v.Required(req.Name, "name")
|
|
v.RequiredSlice(req.Scopes, "scopes")
|
|
return v.Error()
|
|
}
|
|
|
|
// Valid request
|
|
if err := validate(CreateKeyRequest{Name: "my-key", Scopes: []string{"read"}}); err != nil {
|
|
t.Errorf("expected no error for valid request, got %v", err)
|
|
}
|
|
|
|
// Missing name
|
|
if err := validate(CreateKeyRequest{Name: "", Scopes: []string{"read"}}); err == nil {
|
|
t.Error("expected error for missing name")
|
|
}
|
|
|
|
// Missing scopes
|
|
if err := validate(CreateKeyRequest{Name: "my-key", Scopes: []string{}}); err == nil {
|
|
t.Error("expected error for missing scopes")
|
|
}
|
|
|
|
// Both missing
|
|
err := validate(CreateKeyRequest{Name: "", Scopes: []string{}})
|
|
if err == nil {
|
|
t.Error("expected error for missing name and scopes")
|
|
}
|
|
verrs := AsValidationErrors(err)
|
|
if len(verrs) != 2 {
|
|
t.Errorf("expected 2 validation errors, got %d", len(verrs))
|
|
}
|
|
}
|
|
|
|
func TestValidator_ConfigItemValidation(t *testing.T) {
|
|
// Simulating the ConfigItemRequest validation from claude_config.go
|
|
type ConfigItemRequest struct {
|
|
Name string
|
|
Content string
|
|
}
|
|
|
|
validate := func(req ConfigItemRequest) error {
|
|
v := New()
|
|
v.Required(req.Name, "name")
|
|
v.Required(req.Content, "content")
|
|
v.StringLength(req.Name, "name", 1, 64)
|
|
v.Pattern(req.Name, "name", AlphanumericDashUnderscore, "must be alphanumeric with dashes or underscores")
|
|
return v.Error()
|
|
}
|
|
|
|
// Valid request
|
|
if err := validate(ConfigItemRequest{Name: "my-skill", Content: "# Skill content"}); err != nil {
|
|
t.Errorf("expected no error for valid request, got %v", err)
|
|
}
|
|
|
|
// Invalid name pattern
|
|
err := validate(ConfigItemRequest{Name: "my skill", Content: "content"})
|
|
if err == nil {
|
|
t.Error("expected error for invalid name pattern")
|
|
}
|
|
|
|
// Name too long
|
|
longName := "this-name-is-way-too-long-and-exceeds-the-sixty-four-character-limit"
|
|
err = validate(ConfigItemRequest{Name: longName, Content: "content"})
|
|
if err == nil {
|
|
t.Error("expected error for name too long")
|
|
}
|
|
}
|