From 40a9881ef2381f3f4db2dc004dd11b8a0dd3a6a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:45:57 +0200 Subject: [PATCH] fix(database): skip credential pattern validation for unchanged values Pattern enforcement now conditional on field being dirty (changed vs saved value). Prevents false validation failures when existing records hold legacy credential formats that pre-date the stricter regex rules. --- .../Project/Database/Clickhouse/General.php | 8 +- .../Project/Database/Dragonfly/General.php | 4 +- .../Project/Database/Keydb/General.php | 4 +- .../Project/Database/Mariadb/General.php | 16 +++- .../Project/Database/Mongodb/General.php | 12 ++- .../Project/Database/Mysql/General.php | 16 +++- .../Project/Database/Postgresql/General.php | 12 ++- .../Project/Database/Redis/General.php | 8 +- app/Support/ValidationPatterns.php | 22 ++++- .../DatabaseCredentialDirtyValidationTest.php | 87 +++++++++++++++++++ 10 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/DatabaseCredentialDirtyValidationTest.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 3a367844a..2583c10ea 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,12 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(), - 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user, + ), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 38f11ec21..9e1ea0d10 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(), + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1832091b3..7c8808499 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => ValidationPatterns::databasePasswordRules(), + 'keydbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->keydbPassword !== $this->database->keydb_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index b178dd969..ea6d902e7 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), - 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), - 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password, + ), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbUser !== $this->database->mariadb_user, + ), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password, + ), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database, + ), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 5c08b76e8..3af4b0b2a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,15 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(), - 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username, + ), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password, + ), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database, + ), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 7671ec572..34726bd0a 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), - 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), - 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password, + ), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlUser !== $this->database->mysql_user, + ), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlPassword !== $this->database->mysql_password, + ), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database, + ), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 8c23bce8e..a9a4115fd 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,15 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => ValidationPatterns::databaseIdentifierRules(), - 'postgresPassword' => ValidationPatterns::databasePasswordRules(), - 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), + 'postgresUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresUser !== $this->database->postgres_user, + ), + 'postgresPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->postgresPassword !== $this->database->postgres_password, + ), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresDb !== $this->database->postgres_db, + ), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 114c73a42..c3cc43972 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,12 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => ValidationPatterns::databaseIdentifierRules(), - 'redisPassword' => ValidationPatterns::databasePasswordRules(), + 'redisUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->redisUsername !== $this->database->redis_username, + ), + 'redisPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), 'enableSsl' => 'boolean', ]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index c8f4171eb..09c40a466 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -82,8 +82,12 @@ class ValidationPatterns /** * Get validation rules for database identifier fields (username, database name). + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. */ - public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63): array + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array { $rules = []; @@ -96,7 +100,10 @@ public static function databaseIdentifierRules(bool $required = true, int $minLe $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + } return $rules; } @@ -117,8 +124,12 @@ public static function databaseIdentifierMessages(string $field, string $label = /** * Get validation rules for database password fields. + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. */ - public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128): array + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array { $rules = []; @@ -131,7 +142,10 @@ public static function databasePasswordRules(bool $required = true, int $minLeng $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + } return $rules; } diff --git a/tests/Unit/DatabaseCredentialDirtyValidationTest.php b/tests/Unit/DatabaseCredentialDirtyValidationTest.php new file mode 100644 index 000000000..85063f9e0 --- /dev/null +++ b/tests/Unit/DatabaseCredentialDirtyValidationTest.php @@ -0,0 +1,87 @@ + str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:128'); +}); + +it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:2'); + expect($rules)->toContain('max:64'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); + +// ── databaseIdentifierRules ─────────────────────────────────────────────────── + +it('databaseIdentifierRules includes regex rule by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:63'); +}); + +it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:30'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +});