Skip to content

Commit fadf159

Browse files
feat(api): add support for regex validation in config items (#3054)
* add support for regex validation * improve error handling and loggin * reduce nesting
1 parent 36f6600 commit fadf159

File tree

3 files changed

+411
-0
lines changed

3 files changed

+411
-0
lines changed

api/internal/managers/app/config/config.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"maps"
8+
"regexp"
89

910
"github.com/replicatedhq/embedded-cluster/api/types"
1011
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
@@ -99,6 +100,16 @@ func (m *appConfigManager) ValidateConfigValues(configValues types.AppConfigValu
99100
if isFileType(item) && !isValueBase64Encoded(configValue) {
100101
ve = types.AppendFieldError(ve, item.Name, ErrValueNotBase64Encoded)
101102
}
103+
// check regex validation for text, textarea, password types
104+
if item.Validation != nil && item.Validation.Regex != nil {
105+
if isRegexValidatableType(item) {
106+
if err := validateRegexPattern(item, configValue); err != nil {
107+
ve = types.AppendFieldError(ve, item.Name, err)
108+
}
109+
} else {
110+
m.logger.Warnf("Config item %q has regex validation defined but type %q does not support regex validation (only text, textarea, and password types are supported)", item.Name, item.Type)
111+
}
112+
}
102113
}
103114
}
104115

@@ -325,3 +336,51 @@ func isValueBase64Encoded(configValue kotsv1beta1.ConfigValue) bool {
325336
}
326337
return true
327338
}
339+
340+
// isRegexValidatableType checks if the item type supports regex validation
341+
func isRegexValidatableType(item kotsv1beta1.ConfigItem) bool {
342+
return item.Type == "text" || item.Type == "textarea" || item.Type == "password"
343+
}
344+
345+
// validateRegexPattern validates a config item value against its regex pattern
346+
func validateRegexPattern(item kotsv1beta1.ConfigItem, configValue kotsv1beta1.ConfigValue) error {
347+
// Skip if no regex validation defined
348+
if item.Validation == nil || item.Validation.Regex == nil {
349+
return nil
350+
}
351+
352+
// Skip validation for disabled items
353+
if !isItemEnabled(item.When) {
354+
return nil
355+
}
356+
357+
// Get the value to validate (handle password vs non-password types)
358+
var valueToValidate string
359+
if item.Type == "password" {
360+
valueToValidate = configValue.ValuePlaintext
361+
} else {
362+
valueToValidate = configValue.Value
363+
}
364+
365+
// Skip empty values (optional fields) - only validate if user provided a value
366+
if valueToValidate == "" {
367+
return nil
368+
}
369+
370+
// Compile and validate regex pattern
371+
regex, err := regexp.Compile(item.Validation.Regex.Pattern)
372+
if err != nil {
373+
return fmt.Errorf("invalid regex pattern: %w", err)
374+
}
375+
376+
// Check if value matches pattern
377+
if !regex.MatchString(valueToValidate) {
378+
message := item.Validation.Regex.Message
379+
if message == "" {
380+
message = "Value does not match regex"
381+
}
382+
return fmt.Errorf("%s", message)
383+
}
384+
385+
return nil
386+
}

api/internal/managers/app/config/config_test.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3539,6 +3539,313 @@ func TestValidateConfigValues(t *testing.T) {
35393539
wantErr: true,
35403540
errorFields: []string{"required_file", "required_file_with_invalid_base64"},
35413541
},
3542+
{
3543+
name: "regex validation: valid email passes",
3544+
config: kotsv1beta1.Config{
3545+
Spec: kotsv1beta1.ConfigSpec{
3546+
Groups: []kotsv1beta1.ConfigGroup{
3547+
{
3548+
Name: "group1",
3549+
Items: []kotsv1beta1.ConfigItem{
3550+
{
3551+
Name: "email",
3552+
Type: "text",
3553+
Validation: &kotsv1beta1.ConfigItemValidation{
3554+
Regex: &kotsv1beta1.RegexValidator{
3555+
Pattern: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`,
3556+
Message: "Please enter a valid email address",
3557+
},
3558+
},
3559+
},
3560+
},
3561+
},
3562+
},
3563+
},
3564+
},
3565+
configValues: types.AppConfigValues{
3566+
"email": types.AppConfigValue{Value: "[email protected]"},
3567+
},
3568+
wantErr: false,
3569+
},
3570+
{
3571+
name: "regex validation: invalid input shows custom error message",
3572+
config: kotsv1beta1.Config{
3573+
Spec: kotsv1beta1.ConfigSpec{
3574+
Groups: []kotsv1beta1.ConfigGroup{
3575+
{
3576+
Name: "group1",
3577+
Items: []kotsv1beta1.ConfigItem{
3578+
{
3579+
Name: "email",
3580+
Type: "text",
3581+
Validation: &kotsv1beta1.ConfigItemValidation{
3582+
Regex: &kotsv1beta1.RegexValidator{
3583+
Pattern: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`,
3584+
Message: "Please enter a valid email address",
3585+
},
3586+
},
3587+
},
3588+
},
3589+
},
3590+
},
3591+
},
3592+
},
3593+
configValues: types.AppConfigValues{
3594+
"email": types.AppConfigValue{Value: "invalid-email"},
3595+
},
3596+
wantErr: true,
3597+
errorFields: []string{"email"},
3598+
},
3599+
{
3600+
name: "regex validation: invalid input shows default message when no custom message",
3601+
config: kotsv1beta1.Config{
3602+
Spec: kotsv1beta1.ConfigSpec{
3603+
Groups: []kotsv1beta1.ConfigGroup{
3604+
{
3605+
Name: "group1",
3606+
Items: []kotsv1beta1.ConfigItem{
3607+
{
3608+
Name: "code",
3609+
Type: "text",
3610+
Validation: &kotsv1beta1.ConfigItemValidation{
3611+
Regex: &kotsv1beta1.RegexValidator{
3612+
Pattern: `^[A-Z]{3}$`,
3613+
},
3614+
},
3615+
},
3616+
},
3617+
},
3618+
},
3619+
},
3620+
},
3621+
configValues: types.AppConfigValues{
3622+
"code": types.AppConfigValue{Value: "abc"},
3623+
},
3624+
wantErr: true,
3625+
errorFields: []string{"code"},
3626+
},
3627+
{
3628+
name: "regex validation: empty values skip validation (optional fields)",
3629+
config: kotsv1beta1.Config{
3630+
Spec: kotsv1beta1.ConfigSpec{
3631+
Groups: []kotsv1beta1.ConfigGroup{
3632+
{
3633+
Name: "group1",
3634+
Items: []kotsv1beta1.ConfigItem{
3635+
{
3636+
Name: "optional_email",
3637+
Type: "text",
3638+
Validation: &kotsv1beta1.ConfigItemValidation{
3639+
Regex: &kotsv1beta1.RegexValidator{
3640+
Pattern: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`,
3641+
},
3642+
},
3643+
},
3644+
},
3645+
},
3646+
},
3647+
},
3648+
},
3649+
configValues: types.AppConfigValues{
3650+
"optional_email": types.AppConfigValue{Value: ""},
3651+
},
3652+
wantErr: false,
3653+
},
3654+
{
3655+
name: "regex validation: password fields use ValuePlaintext",
3656+
config: kotsv1beta1.Config{
3657+
Spec: kotsv1beta1.ConfigSpec{
3658+
Groups: []kotsv1beta1.ConfigGroup{
3659+
{
3660+
Name: "group1",
3661+
Items: []kotsv1beta1.ConfigItem{
3662+
{
3663+
Name: "strong_password",
3664+
Type: "password",
3665+
Validation: &kotsv1beta1.ConfigItemValidation{
3666+
Regex: &kotsv1beta1.RegexValidator{
3667+
Pattern: `^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$`,
3668+
Message: "Password must be at least 8 characters with uppercase, lowercase, and number",
3669+
},
3670+
},
3671+
},
3672+
},
3673+
},
3674+
},
3675+
},
3676+
},
3677+
configValues: types.AppConfigValues{
3678+
"strong_password": types.AppConfigValue{Value: "weak"},
3679+
},
3680+
wantErr: true,
3681+
errorFields: []string{"strong_password"},
3682+
},
3683+
{
3684+
name: "regex validation: textarea type validates correctly",
3685+
config: kotsv1beta1.Config{
3686+
Spec: kotsv1beta1.ConfigSpec{
3687+
Groups: []kotsv1beta1.ConfigGroup{
3688+
{
3689+
Name: "group1",
3690+
Items: []kotsv1beta1.ConfigItem{
3691+
{
3692+
Name: "json_config",
3693+
Type: "textarea",
3694+
Validation: &kotsv1beta1.ConfigItemValidation{
3695+
Regex: &kotsv1beta1.RegexValidator{
3696+
Pattern: `^\{.*\}$`,
3697+
Message: "Must be a valid JSON object",
3698+
},
3699+
},
3700+
},
3701+
},
3702+
},
3703+
},
3704+
},
3705+
},
3706+
configValues: types.AppConfigValues{
3707+
"json_config": types.AppConfigValue{Value: `{"key": "value"}`},
3708+
},
3709+
wantErr: false,
3710+
},
3711+
{
3712+
name: "regex validation: when=false items skip validation",
3713+
config: kotsv1beta1.Config{
3714+
Spec: kotsv1beta1.ConfigSpec{
3715+
Groups: []kotsv1beta1.ConfigGroup{
3716+
{
3717+
Name: "group1",
3718+
Items: []kotsv1beta1.ConfigItem{
3719+
{
3720+
Name: "disabled_field",
3721+
Type: "text",
3722+
When: "false",
3723+
Validation: &kotsv1beta1.ConfigItemValidation{
3724+
Regex: &kotsv1beta1.RegexValidator{
3725+
Pattern: `^valid$`,
3726+
},
3727+
},
3728+
},
3729+
},
3730+
},
3731+
},
3732+
},
3733+
},
3734+
configValues: types.AppConfigValues{
3735+
"disabled_field": types.AppConfigValue{Value: "invalid"},
3736+
},
3737+
wantErr: false,
3738+
},
3739+
{
3740+
name: "regex validation: unsupported types skip validation",
3741+
config: kotsv1beta1.Config{
3742+
Spec: kotsv1beta1.ConfigSpec{
3743+
Groups: []kotsv1beta1.ConfigGroup{
3744+
{
3745+
Name: "group1",
3746+
Items: []kotsv1beta1.ConfigItem{
3747+
{
3748+
Name: "bool_field",
3749+
Type: "bool",
3750+
Validation: &kotsv1beta1.ConfigItemValidation{
3751+
Regex: &kotsv1beta1.RegexValidator{
3752+
Pattern: `^0$`,
3753+
},
3754+
},
3755+
},
3756+
{
3757+
Name: "file_field",
3758+
Type: "file",
3759+
Validation: &kotsv1beta1.ConfigItemValidation{
3760+
Regex: &kotsv1beta1.RegexValidator{
3761+
Pattern: `^valid$`,
3762+
},
3763+
},
3764+
},
3765+
{
3766+
Name: "radio_field",
3767+
Type: "radio",
3768+
Validation: &kotsv1beta1.ConfigItemValidation{
3769+
Regex: &kotsv1beta1.RegexValidator{
3770+
Pattern: `^valid$`,
3771+
},
3772+
},
3773+
},
3774+
},
3775+
},
3776+
},
3777+
},
3778+
},
3779+
configValues: types.AppConfigValues{
3780+
"bool_field": types.AppConfigValue{Value: "1"},
3781+
"file_field": types.AppConfigValue{Value: "aW52YWxpZA=="}, // base64 "invalid"
3782+
"radio_field": types.AppConfigValue{Value: "invalid"},
3783+
},
3784+
wantErr: false,
3785+
},
3786+
{
3787+
name: "regex validation: multiple validation errors (required + regex)",
3788+
config: kotsv1beta1.Config{
3789+
Spec: kotsv1beta1.ConfigSpec{
3790+
Groups: []kotsv1beta1.ConfigGroup{
3791+
{
3792+
Name: "group1",
3793+
Items: []kotsv1beta1.ConfigItem{
3794+
{
3795+
Name: "email",
3796+
Type: "text",
3797+
Required: true,
3798+
},
3799+
{
3800+
Name: "phone",
3801+
Type: "text",
3802+
Validation: &kotsv1beta1.ConfigItemValidation{
3803+
Regex: &kotsv1beta1.RegexValidator{
3804+
Pattern: `^\d{3}-\d{3}-\d{4}$`,
3805+
Message: "Phone must be in format XXX-XXX-XXXX",
3806+
},
3807+
},
3808+
},
3809+
},
3810+
},
3811+
},
3812+
},
3813+
},
3814+
configValues: types.AppConfigValues{
3815+
"email": types.AppConfigValue{Value: ""},
3816+
"phone": types.AppConfigValue{Value: "123-456"},
3817+
},
3818+
wantErr: true,
3819+
errorFields: []string{"email", "phone"},
3820+
},
3821+
{
3822+
name: "regex validation: invalid regex pattern returns error",
3823+
config: kotsv1beta1.Config{
3824+
Spec: kotsv1beta1.ConfigSpec{
3825+
Groups: []kotsv1beta1.ConfigGroup{
3826+
{
3827+
Name: "group1",
3828+
Items: []kotsv1beta1.ConfigItem{
3829+
{
3830+
Name: "test",
3831+
Type: "text",
3832+
Validation: &kotsv1beta1.ConfigItemValidation{
3833+
Regex: &kotsv1beta1.RegexValidator{
3834+
Pattern: `[invalid(`,
3835+
},
3836+
},
3837+
},
3838+
},
3839+
},
3840+
},
3841+
},
3842+
},
3843+
configValues: types.AppConfigValues{
3844+
"test": types.AppConfigValue{Value: "value"},
3845+
},
3846+
wantErr: true,
3847+
errorFields: []string{"test"},
3848+
},
35423849
}
35433850

35443851
for _, tt := range tests {

0 commit comments

Comments
 (0)