From aafa204307ecb35cbcba66ff9b0d063fa4fe3108 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Mon, 26 Jan 2026 12:41:43 +0100 Subject: [PATCH 1/2] Fix input overwrite not propagating to adjacent results Adjacent results are results that treat the same input. When overwriting the input of a result, we should also overwrite the input of its adjacent result to maintain consistency. Currently, there are no cases where this has caused issues, but this change prevents potential problems. Assisted-by: Claude Code (Opus 4.5) --- src/Result.php | 34 ++++- tests/unit/ResultTest.php | 307 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 tests/unit/ResultTest.php diff --git a/src/Result.php b/src/Result.php index 2c77b8fc7..46e24c790 100644 --- a/src/Result.php +++ b/src/Result.php @@ -77,7 +77,7 @@ public function asAdjacentOf(Result $result, string $prefix): Result if ($this->allowsAdjacent()) { return clone ($result, [ 'id' => $this->id->withPrefix($prefix), - 'adjacent' => clone($this, ['input' => $result->input]), + 'adjacent' => $this->withInput($result->input), ]); } @@ -145,7 +145,10 @@ public function withoutName(): self return clone ($this, [ 'name' => null, 'adjacent' => $this->adjacent?->withoutName(), - 'children' => $this->mapChildren(fn(Result $r) => $r->name === $this->name ? $r->withoutName() : $r), + 'children' => $this->mapChildrenIf( + fn(Result $child) => $child->name === $this->name, + static fn(Result $child) => $child->withoutName(), + ), ]); } @@ -164,7 +167,10 @@ public function withName(Name $name): self 'name' => $name, 'hasPrecedentName' => $this->path === null, 'adjacent' => $this->adjacent?->withName($name), - 'children' => $this->mapChildren(static fn(Result $r) => $r->name === null ? $r->withName($name) : $r), + 'children' => $this->mapChildrenIf( + static fn(Result $child) => $child->name === null, + static fn(Result $child) => $child->withName($name), + ), ]); } @@ -185,6 +191,18 @@ public function withAdjacent(Result $adjacent): self return clone($this, ['adjacent' => $adjacent]); } + public function withInput(mixed $input): self + { + return clone($this, [ + 'input' => $input, + 'adjacent' => $this->adjacent?->withInput($input), + 'children' => $this->mapChildrenIf( + fn(Result $child) => $child->input === $this->input && $child->path === $this->path, + static fn(Result $child) => $child->withInput($input), + ), + ]); + } + public function withToggledValidation(): self { return clone($this, [ @@ -227,4 +245,14 @@ private function mapChildren(callable $callback): array { return $this->children === [] ? [] : array_map($callback, $this->children); } + + /** @return array */ + private function mapChildrenIf(callable $condition, callable $callback): array + { + if ($this->children === []) { + return []; + } + + return array_map(static fn(self $child) => $condition($child) ? $callback($child) : $child, $this->children); + } } diff --git a/tests/unit/ResultTest.php b/tests/unit/ResultTest.php new file mode 100644 index 000000000..12501ed3b --- /dev/null +++ b/tests/unit/ResultTest.php @@ -0,0 +1,307 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use Respect\Validation\Test\Builders\ResultBuilder; +use Respect\Validation\Test\TestCase; + +#[Group('core')] +#[CoversClass(Result::class)] +final class ResultTest extends TestCase +{ + #[Test] + public function itShouldUpdateInputWhenWithInputIsCalled(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + + $result = (new ResultBuilder()) + ->input($originalInput) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + } + + #[Test] + public function itShouldUpdateAdjacentInputWhenWithInputIsCalled(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + + $adjacent = (new ResultBuilder()) + ->input($originalInput) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->adjacent($adjacent) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($newInput, $updatedResult->adjacent?->input); + self::assertSame($originalInput, $result->adjacent?->input); + } + + #[Test] + public function itShouldUpdateChildrenInputWhenWithInputIsCalledAndChildHasSameInputAndPath(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + $path = new Path('parent'); + + $child = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($newInput, $updatedResult->children[0]->input); + self::assertSame($originalInput, $result->children[0]->input); + } + + #[Test] + public function itShouldUpdateChildrenInputWhenWithInputIsCalledAndBothHaveNullPath(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + + $child = (new ResultBuilder()) + ->input($originalInput) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($newInput, $updatedResult->children[0]->input); + } + + #[Test] + public function itShouldNotUpdateChildrenInputWhenWithInputIsCalledAndChildHasDifferentInput(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + $childInput = 'different'; + + $child = (new ResultBuilder()) + ->input($childInput) + ->path(new Path('parent')) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('parent')) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($childInput, $updatedResult->children[0]->input); + } + + #[Test] + public function itShouldUpdateOnlyMatchingChildrenInputWhenWithInputIsCalled(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + $differentInput = 'different'; + $path = new Path('parent'); + + $matchingChild = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->build(); + + $differentChild = (new ResultBuilder()) + ->input($differentInput) + ->path($path) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->children($matchingChild, $differentChild) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($newInput, $updatedResult->children[0]->input); + self::assertSame($differentInput, $updatedResult->children[1]->input); + } + + #[Test] + public function itShouldNotUpdateChildrenInputWhenWithInputIsCalledAndChildHasDifferentPath(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + + $child = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('child')) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('parent')) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($originalInput, $updatedResult->children[0]->input); + } + + #[Test] + public function itShouldUpdateInputAdjacentAndChildrenWithSameInputWhenWithInputIsCalled(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + $path = new Path('parent'); + + $adjacent = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->build(); + + $child = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->adjacent($adjacent) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertNotNull($updatedResult->adjacent); + self::assertSame($newInput, $updatedResult->adjacent->input); + self::assertSame($newInput, $updatedResult->children[0]->input); + } + + #[Test] + public function itShouldUpdateNestedChildrenInputWhenWithInputIsCalled(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + $path = new Path('parent'); + + $grandchild = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->build(); + + $child = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->children($grandchild) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path($path) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($newInput, $updatedResult->children[0]->input); + self::assertSame($newInput, $updatedResult->children[0]->children[0]->input); + } + + #[Test] + public function itShouldNotUpdateNestedChildrenWhenWithInputIsCalledAndGrandchildHasDifferentPath(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + $grandchildInput = 'grandchild_input'; + + $grandchild = (new ResultBuilder()) + ->input($grandchildInput) + ->path(new Path('grandchild')) + ->build(); + + $child = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('child')) + ->children($grandchild) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('parent')) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($originalInput, $updatedResult->children[0]->input); + self::assertSame($grandchildInput, $updatedResult->children[0]->children[0]->input); + } + + #[Test] + public function itShouldUpdateNestedChildrenWhenWithInputIsCalledAndGrandchildHasSameInputAndPath(): void + { + $originalInput = 'original'; + $newInput = 'updated'; + + $grandchild = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('grandchild')) + ->build(); + + $child = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('child')) + ->children($grandchild) + ->build(); + + $result = (new ResultBuilder()) + ->input($originalInput) + ->path(new Path('result')) + ->children($child) + ->build(); + + $updatedResult = $result->withInput($newInput); + + self::assertSame($newInput, $updatedResult->input); + self::assertSame($originalInput, $updatedResult->children[0]->input); + self::assertSame($originalInput, $updatedResult->children[0]->children[0]->input); + } +} From 882f24b6b8a84af0097f83835cf0c825809869fb Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Wed, 14 Jan 2026 22:19:33 -0700 Subject: [PATCH 2/2] Create "Masked" validator The Masked validator decorates other validators to mask sensitive input values in error messages while still validating the original unmasked data. This validator is essential for applications handling sensitive information such as passwords, credit cards, or email addresses. Without it, users would need to implement a custom layer between Validation and the end user to prevent PII from appearing in error messages or logs. With Masked, sensitive data protection is built directly into the validation workflow with no additional abstraction required. Assisted-by: Claude Code (Opus 4.5) --- docs/migrating-from-v2-to-v3.md | 13 ++++++ docs/validators.md | 8 +++- docs/validators/Masked.md | 49 +++++++++++++++++++++ docs/validators/Named.md | 3 +- docs/validators/Templated.md | 3 +- src/Mixins/AllBuilder.php | 2 + src/Mixins/AllChain.php | 2 + src/Mixins/Builder.php | 2 + src/Mixins/Chain.php | 2 + src/Mixins/KeyBuilder.php | 2 + src/Mixins/KeyChain.php | 2 + src/Mixins/NotBuilder.php | 2 + src/Mixins/NotChain.php | 2 + src/Mixins/NullOrBuilder.php | 2 + src/Mixins/NullOrChain.php | 2 + src/Mixins/PropertyBuilder.php | 2 + src/Mixins/PropertyChain.php | 2 + src/Mixins/UndefOrBuilder.php | 2 + src/Mixins/UndefOrChain.php | 2 + src/Validators/Masked.php | 47 ++++++++++++++++++++ tests/feature/Validators/MaskedTest.php | 25 +++++++++++ tests/fixtures/data-provider.php | 31 +++++++------ tests/src/SmokeTestProvider.php | 1 + tests/src/Stubs/ToStringStub.php | 4 +- tests/src/TestCase.php | 10 +++++ tests/unit/Validators/MaskedTest.php | 58 +++++++++++++++++++++++++ 26 files changed, 262 insertions(+), 18 deletions(-) create mode 100644 docs/validators/Masked.md create mode 100644 src/Validators/Masked.php create mode 100644 tests/feature/Validators/MaskedTest.php create mode 100644 tests/unit/Validators/MaskedTest.php diff --git a/docs/migrating-from-v2-to-v3.md b/docs/migrating-from-v2-to-v3.md index f66f96f4f..0e5c796dc 100644 --- a/docs/migrating-from-v2-to-v3.md +++ b/docs/migrating-from-v2-to-v3.md @@ -575,6 +575,7 @@ Version 3.0 introduces several new validators: | `KeyExists` | Checks if an array key exists | | `KeyOptional` | Validates an array key only if it exists | | `Lazy` | Creates validators dynamically based on input | +| `Masked` | Masks sensitive input values in error messages | | `Named` | Customizes the subject name in error messages | | `PropertyExists` | Checks if an object property exists | | `PropertyOptional` | Validates an object property only if it exists | @@ -701,6 +702,18 @@ v::lazy( )->assert(['password' => 'secret', 'confirmation' => 'secret']); // passes ``` +#### Masked + +Decorates a validator to mask sensitive input values in error messages while still validating the original unmasked data. This allows applications to protect sensitive information like passwords, credit cards, or emails without implementing a custom layer between Validation and end users: + +```php +v::masked('1-@', v::email(),v::email())->assert('invalid@example.com'); +// → "*******@example.com" must be a valid email address + +v::masked('6-12', v::creditCard(), 'X')->assert('4111111111111211'); +// → "41111XXXXXXX1211" must be a valid credit card number +``` + #### Named Customizes the subject name in error messages: diff --git a/docs/validators.md b/docs/validators.md index 1d8cce3ab..c76281fb8 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -25,6 +25,8 @@ In this page you will find a list of validators by their category. **Date and Time**: [Date][] - [DateTime][] - [DateTimeDiff][] - [LeapDate][] - [LeapYear][] - [Time][] +**Display**: [Masked][] - [Named][] - [Templated][] + **File system**: [Directory][] - [Executable][] - [Exists][] - [Extension][] - [File][] - [Image][] - [Mimetype][] - [Readable][] - [Size][] - [SymbolicLink][] - [Writable][] **ISO codes**: [CountryCode][] - [CurrencyCode][] - [LanguageCode][] - [SubdivisionCode][] @@ -37,7 +39,7 @@ In this page you will find a list of validators by their category. **Math**: [Factor][] - [Finite][] - [Infinite][] - [Multiple][] - [Negative][] - [Positive][] -**Miscellaneous**: [Blank][] - [Falsy][] - [Named][] - [Templated][] - [Undef][] +**Miscellaneous**: [Blank][] - [Falsy][] - [Masked][] - [Named][] - [Templated][] - [Undef][] **Nesting**: [AllOf][] - [AnyOf][] - [Call][] - [Circuit][] - [Each][] - [Key][] - [KeySet][] - [Lazy][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [UndefOr][] - [When][] @@ -47,7 +49,7 @@ In this page you will find a list of validators by their category. **Strings**: [Alnum][] - [Alpha][] - [Base64][] - [Charset][] - [Consonant][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Control][] - [Digit][] - [Emoji][] - [EndsWith][] - [Graph][] - [HexRgbColor][] - [In][] - [Json][] - [Lowercase][] - [Phone][] - [PostalCode][] - [Printable][] - [Punct][] - [Regex][] - [Slug][] - [Sorted][] - [Space][] - [Spaced][] - [StartsWith][] - [StringType][] - [StringVal][] - [Uppercase][] - [Uuid][] - [Version][] - [Vowel][] - [Xdigit][] -**Structures**: [Attributes][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Named][] - [Property][] - [PropertyExists][] - [PropertyOptional][] - [Templated][] +**Structures**: [Attributes][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Property][] - [PropertyExists][] - [PropertyOptional][] **Transformations**: [All][] - [Call][] - [Each][] - [Length][] - [Max][] - [Min][] - [Size][] @@ -147,6 +149,7 @@ In this page you will find a list of validators by their category. - [Lowercase][] - `v::stringType()->lowercase()->assert('xkcd');` - [Luhn][] - `v::luhn()->assert('2222400041240011');` - [MacAddress][] - `v::macAddress()->assert('00:11:22:33:44:55');` +- [Masked][] - `v::masked('1-@', v::email())->assert('foo@example.com');` - [Max][] - `v::max(v::equals(30))->assert([10, 20, 30]);` - [Mimetype][] - `v::mimetype('image/png')->assert('/path/to/image.png');` - [Min][] - `v::min(v::equals(10))->assert([10, 20, 30]);` @@ -302,6 +305,7 @@ In this page you will find a list of validators by their category. [Lowercase]: validators/Lowercase.md "Validates whether the characters in the input are lowercase." [Luhn]: validators/Luhn.md "Validate whether a given input is a Luhn number." [MacAddress]: validators/MacAddress.md "Validates whether the input is a valid MAC address." +[Masked]: validators/Masked.md "Decorates a validator to mask input values in error messages while still validating the original unmasked input." [Max]: validators/Max.md "Validates the maximum value of the input against a given validator." [Mimetype]: validators/Mimetype.md "Validates if the input is a file and if its MIME type matches the expected one." [Min]: validators/Min.md "Validates the minimum value of the input against a given validator." diff --git a/docs/validators/Masked.md b/docs/validators/Masked.md new file mode 100644 index 000000000..5ee94204a --- /dev/null +++ b/docs/validators/Masked.md @@ -0,0 +1,49 @@ + + +# Masked + +- `Masked(string $range, Validator $validator)` +- `Masked(string $range, Validator $validator, string $replacement)` + +Decorates a validator to mask input values in error messages while still validating the original unmasked input. + +```php +v::masked('1-@', v::email())->assert('foo@example.com'); +// Validation passes successfully + +v::masked('1-@', v::email())->assert('invalid username@domain.com'); +// → "****************@domain.com" must be a valid email address + +v::masked('1-', v::lengthGreaterThan(10))->assert('password'); +// → The length of "********" must be greater than 10 + +v::masked('6-12', v::creditCard(), 'X')->assert('4111111111111211'); +// → "41111XXXXXXX1211" must be a valid credit card number +``` + +This validator is useful for security-sensitive applications where error messages should not expose sensitive data like credit card numbers, passwords, or email addresses. + +It uses [respect/string-formatter](https://github.com/Respect/StringFormatter) as the underlying masking engine. See the section the documentation of [MaskFormatter](https://github.com/Respect/StringFormatter/blob/main/docs/MaskFormatter.md) for more information. + +## Categorization + +- Display +- Miscellaneous + +## Behavior + +The validator first ensures the input is a valid string using `StringVal`. If the input passes string validation, it validates the original unmasked input using the inner validator. If validation fails, it applies masking to the input value shown in error messages. + +## Changelog + +| Version | Description | +| ------: | :---------- | +| 3.0.0 | Created | + +## See Also + +- [Named](Named.md) +- [Templated](Templated.md) diff --git a/docs/validators/Named.md b/docs/validators/Named.md index 711d684f8..62af56bcf 100644 --- a/docs/validators/Named.md +++ b/docs/validators/Named.md @@ -39,7 +39,7 @@ This validator does not have any templates, as it will use the template of the g ## Categorization - Core -- Structures +- Display - Miscellaneous ## Changelog @@ -51,5 +51,6 @@ This validator does not have any templates, as it will use the template of the g ## See Also - [Attributes](Attributes.md) +- [Masked](Masked.md) - [Not](Not.md) - [Templated](Templated.md) diff --git a/docs/validators/Templated.md b/docs/validators/Templated.md index 357b281e1..85e12fea6 100644 --- a/docs/validators/Templated.md +++ b/docs/validators/Templated.md @@ -40,7 +40,7 @@ This validator does not have any templates, as you must define the templates you ## Categorization - Core -- Structures +- Display - Miscellaneous ## Changelog @@ -52,5 +52,6 @@ This validator does not have any templates, as you must define the templates you ## See Also - [Attributes](Attributes.md) +- [Masked](Masked.md) - [Named](Named.md) - [Not](Not.md) diff --git a/src/Mixins/AllBuilder.php b/src/Mixins/AllBuilder.php index 5bcfa9011..4334c708b 100644 --- a/src/Mixins/AllBuilder.php +++ b/src/Mixins/AllBuilder.php @@ -193,6 +193,8 @@ public static function allLuhn(): Chain; public static function allMacAddress(): Chain; + public static function allMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public static function allMax(Validator $validator): Chain; public static function allMimetype(string $mimetype): Chain; diff --git a/src/Mixins/AllChain.php b/src/Mixins/AllChain.php index 63b213a1f..3e2bec307 100644 --- a/src/Mixins/AllChain.php +++ b/src/Mixins/AllChain.php @@ -193,6 +193,8 @@ public function allLuhn(): Chain; public function allMacAddress(): Chain; + public function allMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public function allMax(Validator $validator): Chain; public function allMimetype(string $mimetype): Chain; diff --git a/src/Mixins/Builder.php b/src/Mixins/Builder.php index b93e3af8f..a28983d1a 100644 --- a/src/Mixins/Builder.php +++ b/src/Mixins/Builder.php @@ -208,6 +208,8 @@ public static function luhn(): Chain; public static function macAddress(): Chain; + public static function masked(string $range, Validator $validator, string $replacement = '*'): Chain; + public static function max(Validator $validator): Chain; public static function mimetype(string $mimetype): Chain; diff --git a/src/Mixins/Chain.php b/src/Mixins/Chain.php index 432aa52b1..1500fb49f 100644 --- a/src/Mixins/Chain.php +++ b/src/Mixins/Chain.php @@ -210,6 +210,8 @@ public function luhn(): Chain; public function macAddress(): Chain; + public function masked(string $range, Validator $validator, string $replacement = '*'): Chain; + public function max(Validator $validator): Chain; public function mimetype(string $mimetype): Chain; diff --git a/src/Mixins/KeyBuilder.php b/src/Mixins/KeyBuilder.php index ee78982e3..617e05081 100644 --- a/src/Mixins/KeyBuilder.php +++ b/src/Mixins/KeyBuilder.php @@ -195,6 +195,8 @@ public static function keyLuhn(int|string $key): Chain; public static function keyMacAddress(int|string $key): Chain; + public static function keyMasked(int|string $key, string $range, Validator $validator, string $replacement = '*'): Chain; + public static function keyMax(int|string $key, Validator $validator): Chain; public static function keyMimetype(int|string $key, string $mimetype): Chain; diff --git a/src/Mixins/KeyChain.php b/src/Mixins/KeyChain.php index d038f9c65..3addc3cee 100644 --- a/src/Mixins/KeyChain.php +++ b/src/Mixins/KeyChain.php @@ -195,6 +195,8 @@ public function keyLuhn(int|string $key): Chain; public function keyMacAddress(int|string $key): Chain; + public function keyMasked(int|string $key, string $range, Validator $validator, string $replacement = '*'): Chain; + public function keyMax(int|string $key, Validator $validator): Chain; public function keyMimetype(int|string $key, string $mimetype): Chain; diff --git a/src/Mixins/NotBuilder.php b/src/Mixins/NotBuilder.php index b07b0c760..ed6b4a5a3 100644 --- a/src/Mixins/NotBuilder.php +++ b/src/Mixins/NotBuilder.php @@ -205,6 +205,8 @@ public static function notLuhn(): Chain; public static function notMacAddress(): Chain; + public static function notMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public static function notMax(Validator $validator): Chain; public static function notMimetype(string $mimetype): Chain; diff --git a/src/Mixins/NotChain.php b/src/Mixins/NotChain.php index d75fb6c47..2bd5b6c9f 100644 --- a/src/Mixins/NotChain.php +++ b/src/Mixins/NotChain.php @@ -205,6 +205,8 @@ public function notLuhn(): Chain; public function notMacAddress(): Chain; + public function notMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public function notMax(Validator $validator): Chain; public function notMimetype(string $mimetype): Chain; diff --git a/src/Mixins/NullOrBuilder.php b/src/Mixins/NullOrBuilder.php index d1d123405..d2d6a7d15 100644 --- a/src/Mixins/NullOrBuilder.php +++ b/src/Mixins/NullOrBuilder.php @@ -205,6 +205,8 @@ public static function nullOrLuhn(): Chain; public static function nullOrMacAddress(): Chain; + public static function nullOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public static function nullOrMax(Validator $validator): Chain; public static function nullOrMimetype(string $mimetype): Chain; diff --git a/src/Mixins/NullOrChain.php b/src/Mixins/NullOrChain.php index 0d6664a71..074794e63 100644 --- a/src/Mixins/NullOrChain.php +++ b/src/Mixins/NullOrChain.php @@ -205,6 +205,8 @@ public function nullOrLuhn(): Chain; public function nullOrMacAddress(): Chain; + public function nullOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public function nullOrMax(Validator $validator): Chain; public function nullOrMimetype(string $mimetype): Chain; diff --git a/src/Mixins/PropertyBuilder.php b/src/Mixins/PropertyBuilder.php index 6712d07a8..ff7564915 100644 --- a/src/Mixins/PropertyBuilder.php +++ b/src/Mixins/PropertyBuilder.php @@ -195,6 +195,8 @@ public static function propertyLuhn(string $propertyName): Chain; public static function propertyMacAddress(string $propertyName): Chain; + public static function propertyMasked(string $propertyName, string $range, Validator $validator, string $replacement = '*'): Chain; + public static function propertyMax(string $propertyName, Validator $validator): Chain; public static function propertyMimetype(string $propertyName, string $mimetype): Chain; diff --git a/src/Mixins/PropertyChain.php b/src/Mixins/PropertyChain.php index 668fa7c89..f51c244bf 100644 --- a/src/Mixins/PropertyChain.php +++ b/src/Mixins/PropertyChain.php @@ -195,6 +195,8 @@ public function propertyLuhn(string $propertyName): Chain; public function propertyMacAddress(string $propertyName): Chain; + public function propertyMasked(string $propertyName, string $range, Validator $validator, string $replacement = '*'): Chain; + public function propertyMax(string $propertyName, Validator $validator): Chain; public function propertyMimetype(string $propertyName, string $mimetype): Chain; diff --git a/src/Mixins/UndefOrBuilder.php b/src/Mixins/UndefOrBuilder.php index 6c0b046e6..b6b756172 100644 --- a/src/Mixins/UndefOrBuilder.php +++ b/src/Mixins/UndefOrBuilder.php @@ -203,6 +203,8 @@ public static function undefOrLuhn(): Chain; public static function undefOrMacAddress(): Chain; + public static function undefOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public static function undefOrMax(Validator $validator): Chain; public static function undefOrMimetype(string $mimetype): Chain; diff --git a/src/Mixins/UndefOrChain.php b/src/Mixins/UndefOrChain.php index adb8f85f4..c14c07029 100644 --- a/src/Mixins/UndefOrChain.php +++ b/src/Mixins/UndefOrChain.php @@ -203,6 +203,8 @@ public function undefOrLuhn(): Chain; public function undefOrMacAddress(): Chain; + public function undefOrMasked(string $range, Validator $validator, string $replacement = '*'): Chain; + public function undefOrMax(Validator $validator): Chain; public function undefOrMimetype(string $mimetype): Chain; diff --git a/src/Validators/Masked.php b/src/Validators/Masked.php new file mode 100644 index 000000000..a3162c741 --- /dev/null +++ b/src/Validators/Masked.php @@ -0,0 +1,47 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use Attribute; +use Respect\StringFormatter\InvalidFormatterException; +use Respect\StringFormatter\MaskFormatter; +use Respect\Validation\Exceptions\InvalidValidatorException; +use Respect\Validation\Result; +use Respect\Validation\Validator; + +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +final readonly class Masked implements Validator +{ + private MaskFormatter $maskFormatter; + + public function __construct( + private string $range, + private Validator $validator, + private string $replacement = '*', + ) { + try { + $this->maskFormatter = new MaskFormatter($this->range, $this->replacement); + } catch (InvalidFormatterException $exception) { + throw new InvalidValidatorException($exception->getMessage()); + } + } + + public function evaluate(mixed $input): Result + { + $stringVal = new StringVal(); + $stringValResult = $stringVal->evaluate($input); + if (!$stringValResult->hasPassed) { + return $stringValResult->withNameFrom($this->validator)->withIdFrom($this->validator); + } + + return $this->validator->evaluate($input)->withInput($this->maskFormatter->format((string) $input)); + } +} diff --git a/tests/feature/Validators/MaskedTest.php b/tests/feature/Validators/MaskedTest.php new file mode 100644 index 000000000..9ac21912c --- /dev/null +++ b/tests/feature/Validators/MaskedTest.php @@ -0,0 +1,25 @@ + + */ + +declare(strict_types=1); + +test('input is not a string', catchAll( + fn() => v::masked('1-@', v::email())->assert(new stdClass()), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`stdClass {}` must be a string value') + ->and($fullMessage)->toBe('- `stdClass {}` must be a string value') + ->and($messages)->toBe(['email' => '`stdClass {}` must be a string value']), +)); + +test('failed validator', catchAll( + fn() => v::masked('1-@', v::email())->assert('in valid@email.com'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"********@email.com" must be a valid email address') + ->and($fullMessage)->toBe('- "********@email.com" must be a valid email address') + ->and($messages)->toBe(['email' => '"********@email.com" must be a valid email address']), +)); diff --git a/tests/fixtures/data-provider.php b/tests/fixtures/data-provider.php index 602190039..a7f1382be 100644 --- a/tests/fixtures/data-provider.php +++ b/tests/fixtures/data-provider.php @@ -8,6 +8,7 @@ declare(strict_types=1); +use Respect\Validation\Test\Stubs\ToStringStub; use Respect\Validation\Test\Stubs\WithAttributes; use Respect\Validation\Test\Stubs\WithProperties; use Respect\Validation\Test\Stubs\WithStaticProperties; @@ -22,61 +23,61 @@ // BooleanTypes 'false' => [ 'value' => [false], - 'tags' => ['boolType', 'false', 'empty'], + 'tags' => ['boolType', 'false', 'empty', 'stringVal'], ], 'true' => [ 'value' => [true], - 'tags' => ['boolType', 'true'], + 'tags' => ['boolType', 'true', 'stringVal'], ], // IntegerTypes 'zero integer' => [ 'value' => [0], - 'tags' => ['intType', 'zero'], + 'tags' => ['intType', 'zero', 'stringVal'], ], 'positive integer' => [ 'value' => [PHP_INT_MAX], - 'tags' => ['intType', 'positive'], + 'tags' => ['intType', 'positive', 'stringVal'], ], 'negative integer' => [ 'value' => [PHP_INT_MIN], - 'tags' => ['intType', 'negative'], + 'tags' => ['intType', 'negative', 'stringVal'], ], // StringTypes 'string' => [ 'value' => ['string'], - 'tags' => ['stringType'], + 'tags' => ['stringType', 'stringVal'], ], 'empty string' => [ 'value' => [''], - 'tags' => ['stringType', 'empty', 'undefined'], + 'tags' => ['stringType', 'empty', 'undefined', 'stringVal'], ], 'integer string' => [ 'value' => ['500'], - 'tags' => ['stringType', 'intVal', 'positive'], + 'tags' => ['stringType', 'intVal', 'positive', 'stringVal'], ], 'float string' => [ 'value' => ['56.8'], - 'tags' => ['stringType', 'floatVal', 'positive'], + 'tags' => ['stringType', 'floatVal', 'positive', 'stringVal'], ], 'zero string' => [ 'value' => ['0'], - 'tags' => ['stringType', 'intVal', 'zero'], + 'tags' => ['stringType', 'intVal', 'zero', 'stringVal'], ], // Float types 'zero float' => [ 'value' => [0.0], - 'tags' => ['floatType', 'zero'], + 'tags' => ['floatType', 'zero', 'stringVal'], ], 'positive float' => [ 'value' => [32.890], - 'tags' => ['floatType', 'positive'], + 'tags' => ['floatType', 'positive', 'stringVal'], ], 'negative float' => [ 'value' => [-893.1], - 'tags' => ['floatType', 'negative'], + 'tags' => ['floatType', 'negative', 'stringVal'], ], // Array types @@ -144,6 +145,10 @@ 'value' => [new WithAttributes('John Doe', '1912-06-23', 'john.doe@gmail.com')], 'tags' => ['objectType', 'withAttributes'], ], + 'object implementing Stringable' => [ + 'value' => [new ToStringStub('dataProvider')], + 'tags' => ['objectType', 'withoutAttributes', 'stringVal'], + ], 'anonymous class' => [ 'value' => [ new class { diff --git a/tests/src/SmokeTestProvider.php b/tests/src/SmokeTestProvider.php index 6bd45d34a..062944249 100644 --- a/tests/src/SmokeTestProvider.php +++ b/tests/src/SmokeTestProvider.php @@ -115,6 +115,7 @@ public static function provideValidatorInput(): Generator yield 'Lowercase' => [new vs\Lowercase(), 'abc']; yield 'Luhn' => [new vs\Luhn(), '2222400041240011']; yield 'MacAddress' => [new vs\MacAddress(), '00:11:22:33:44:55']; + yield 'Masked' => [new vs\Masked('1-', new vs\IntVal()), 123]; yield 'Max' => [new vs\Max(new vs\Equals(30)), [10, 20, 30]]; yield 'Min' => [new vs\Min(new vs\Equals(10)), [10, 20, 30]]; yield 'Mimetype' => [new vs\Mimetype('image/png'), 'tests/fixtures/valid-image.png']; diff --git a/tests/src/Stubs/ToStringStub.php b/tests/src/Stubs/ToStringStub.php index d0d2697ca..fa6a996e2 100644 --- a/tests/src/Stubs/ToStringStub.php +++ b/tests/src/Stubs/ToStringStub.php @@ -12,7 +12,9 @@ namespace Respect\Validation\Test\Stubs; -final class ToStringStub +use Stringable; + +final class ToStringStub implements Stringable { public function __construct( private readonly string $value, diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index 01a194dd3..4e0538ded 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -129,6 +129,16 @@ public static function providerForNonStringTypes(): DataProvider return self::providerForAnyValues()->without('stringType'); } + public static function providerForNonStringValues(): DataProvider + { + return self::providerForAnyValues()->without('stringType', 'stringVal'); + } + + public static function providerForStringValues(): DataProvider + { + return self::providerForAnyValues()->withAny('stringType', 'stringVal'); + } + public static function providerForIntegerTypes(): DataProvider { return self::providerForAnyValues()->with('intType'); diff --git a/tests/unit/Validators/MaskedTest.php b/tests/unit/Validators/MaskedTest.php new file mode 100644 index 000000000..40bd01371 --- /dev/null +++ b/tests/unit/Validators/MaskedTest.php @@ -0,0 +1,58 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Respect\StringFormatter\MaskFormatter; +use Respect\Validation\Exceptions\InvalidValidatorException; +use Respect\Validation\Test\TestCase; +use Respect\Validation\Test\Validators\Stub; + +#[CoversClass(Masked::class)] +final class MaskedTest extends TestCase +{ + #[Test] + public function shouldNotAllowCreatingValidatorWithAnInvalidRange(): void + { + $range = '0-3'; + + $this->expectException(InvalidValidatorException::class); + + new Masked($range, Stub::daze()); + } + + #[Test] + #[DataProvider('providerForNonStringValues')] + public function shouldNotValidateWhenInputIsNotStringValue(mixed $input): void + { + $this->assertInvalidInput(new Masked('1-', Stub::any(1)), $input); + } + + #[Test] + #[DataProvider('providerForStringValues')] + public function shouldMaskTheInputWhenInputIsStringValue(mixed $input): void + { + $maskFormatter = new MaskFormatter('1-', '*'); + + $stub = Stub::pass(2); + $comparableResult = $stub->evaluate($input); + + $validator = new Masked('1-', $stub); + + $result = $validator->evaluate($input); + + self::assertSame($maskFormatter->format((string) $input), $result->input); + self::assertSame($comparableResult->hasPassed, $result->hasPassed); + self::assertSame($comparableResult->validator, $result->validator); + } +}