Valinor/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php
Romain Canon 6ce1a439ad feat!: filter userland exceptions to hide potential sensible data
/!\ This change fixes a security issue.

Userland exception thrown in a constructor will not be automatically
caught by the mapper anymore. This prevents messages with sensible
information from reaching the final user — for instance an SQL exception
showing a part of a query.

To allow exceptions to be considered as safe, the new method
`MapperBuilder::filterExceptions()` must be used, with caution.

```php
final class SomeClass
{
    public function __construct(private string $value)
    {
        \Webmozart\Assert\Assert::startsWith($value, 'foo_');
    }
}

try {
    (new \CuyZ\Valinor\MapperBuilder())
        ->filterExceptions(function (Throwable $exception) {
            if ($exception instanceof \Webmozart\Assert\InvalidArgumentException) {
                return \CuyZ\Valinor\Mapper\Tree\Message\ThrowableMessage::from($exception);
            }

            // If the exception should not be caught by this library, it
            // must be thrown again.
            throw $exception;
        })
        ->mapper()
        ->map(SomeClass::class, 'bar_baz');
} catch (\CuyZ\Valinor\Mapper\MappingError $exception) {
    // Should print something similar to:
    // > Expected a value to start with "foo_". Got: "bar_baz"
    echo $exception->node()->messages()[0];
}
```
2022-07-08 13:58:48 +02:00

540 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Object\Exception\CannotInstantiateObject;
use CuyZ\Valinor\Mapper\Object\Exception\ObjectBuildersCollision;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeErrorMessage;
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use stdClass;
final class ConstructorRegistrationMappingTest extends IntegrationTest
{
public function test_registered_anonymous_function_constructor_is_used(): void
{
$object = new stdClass();
try {
$result = (new MapperBuilder())
->registerConstructor(fn (): stdClass => $object)
->mapper()
->map(stdClass::class, []);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($object, $result);
}
public function test_registered_anonymous_function_constructor_with_docblock_is_used(): void
{
$object = new stdClass();
try {
$result = (new MapperBuilder())
->registerConstructor(/** @return stdClass */ fn () => $object)
->mapper()
->map(stdClass::class, []);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($object, $result);
}
public function test_registered_named_constructor_is_used(): void
{
try {
$result = (new MapperBuilder())
// @PHP8.1 first-class callable syntax
->registerConstructor([SomeClassWithNamedConstructors::class, 'namedConstructor'])
->mapper()
->map(SomeClassWithNamedConstructors::class, 'foo');
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->foo);
}
public function test_registered_callable_object_constructor_is_used(): void
{
$constructor = new class () {
public function __invoke(): stdClass
{
$object = new stdClass();
$object->foo = 'foo';
return $object;
}
};
try {
$result = (new MapperBuilder())
->registerConstructor($constructor)
->mapper()
->map(stdClass::class, []);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->foo);
}
public function test_registered_method_constructor_is_used(): void
{
$constructor = new class () {
public function build(): stdClass
{
$object = new stdClass();
$object->foo = 'foo';
return $object;
}
};
try {
$result = (new MapperBuilder())
// @PHP8.1 first-class callable syntax
->registerConstructor([$constructor, 'build'])
->mapper()
->map(stdClass::class, []);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->foo);
}
public function test_native_constructor_is_not_called_if_not_registered_but_other_constructors_are_registered(): void
{
try {
$result = (new MapperBuilder())
// @PHP8.1 first-class callable syntax
->registerConstructor([SomeClassWithSimilarNativeConstructorAndNamedConstructor::class, 'namedConstructor'])
->mapper()
->map(SomeClassWithSimilarNativeConstructorAndNamedConstructor::class, 'foo');
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('value from named constructor', $result->foo);
}
public function test_registered_native_constructor_is_called_if_registered_and_other_constructors_are_registered(): void
{
try {
$result = (new MapperBuilder())
// @PHP8.1 first-class callable syntax
->registerConstructor(SomeClassWithDifferentNativeConstructorAndNamedConstructor::class)
->registerConstructor([SomeClassWithDifferentNativeConstructorAndNamedConstructor::class, 'namedConstructor'])
->mapper()
->map(SomeClassWithDifferentNativeConstructorAndNamedConstructor::class, [
'foo' => 'foo',
'bar' => 1337,
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->foo);
self::assertSame(1337, $result->bar);
}
public function test_registered_constructor_is_used_when_not_the_first_nor_last_one(): void
{
$object = new stdClass();
try {
$result = (new MapperBuilder())
->registerConstructor(fn (): DateTime => new DateTime())
// This constructor is surrounded by other ones to ensure it is
// still used correctly.
->registerConstructor(fn (): stdClass => $object)
->registerConstructor(fn (): DateTimeImmutable => new DateTimeImmutable())
->mapper()
->map(stdClass::class, []);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($object, $result);
}
public function test_registered_constructor_with_one_argument_is_used(): void
{
try {
$result = (new MapperBuilder())
->registerConstructor(function (int $int): stdClass {
$class = new stdClass();
$class->int = $int;
return $class;
})
->mapper()
->map(stdClass::class, 1337);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame(1337, $result->int);
}
public function test_registered_constructor_with_several_arguments_is_used(): void
{
try {
$result = (new MapperBuilder())
->registerConstructor(function (string $string, int $int, float $float = 1337.404): stdClass {
$class = new stdClass();
$class->string = $string;
$class->int = $int;
$class->float = $float;
return $class;
})
->mapper()
->map(stdClass::class, [
'string' => 'foo',
'int' => 42,
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->string);
self::assertSame(42, $result->int);
self::assertSame(1337.404, $result->float);
}
public function test_registered_constructors_for_same_class_are_filtered_correctly(): void
{
$mapper = (new MapperBuilder())
// Basic constructor
->registerConstructor(function (string $foo): stdClass {
$class = new stdClass();
$class->foo = $foo;
return $class;
})
// Constructor with two parameters
->registerConstructor(function (string $foo, int $bar): stdClass {
$class = new stdClass();
$class->foo = $foo;
$class->bar = $bar;
return $class;
})
// Constructor with optional parameter
->registerConstructor(function (string $foo, int $bar, float $baz, string $fiz = 'fiz'): stdClass {
$class = new stdClass();
$class->foo = $foo;
$class->bar = $bar;
$class->baz = $baz;
$class->fiz = $fiz;
return $class;
})
->mapper();
try {
$resultA = $mapper->map(stdClass::class, [
'foo' => 'foo',
]);
$resultB = $mapper->map(stdClass::class, [
'foo' => 'foo',
'bar' => 42,
]);
$resultC = $mapper->map(stdClass::class, [
'foo' => 'foo',
'bar' => 42,
'baz' => 1337.404,
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $resultA->foo);
self::assertSame('foo', $resultB->foo);
self::assertSame(42, $resultB->bar);
self::assertSame('foo', $resultC->foo);
self::assertSame(42, $resultC->bar);
self::assertSame(1337.404, $resultC->baz);
self::assertSame('fiz', $resultC->fiz);
}
public function test_several_constructors_with_same_arguments_number_are_filtered_correctly(): void
{
$mapper = (new MapperBuilder())
->registerConstructor(function (string $foo, string $bar): stdClass {
$class = new stdClass();
$class->foo = $foo;
$class->bar = $bar;
return $class;
})
->registerConstructor(function (string $foo, string $baz): stdClass {
$class = new stdClass();
$class->foo = $foo;
$class->baz = $baz;
return $class;
})->mapper();
try {
$resultA = $mapper->map(stdClass::class, [
'foo' => 'foo',
'bar' => 'bar',
]);
$resultB = $mapper->map(stdClass::class, [
'foo' => 'foo',
'baz' => 'baz',
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $resultA->foo);
self::assertSame('bar', $resultA->bar);
self::assertSame('foo', $resultB->foo);
self::assertSame('baz', $resultB->baz);
}
public function test_identical_registered_constructors_with_no_argument_throws_exception(): void
{
$this->expectException(ObjectBuildersCollision::class);
$this->expectExceptionCode(1654955787);
$this->expectExceptionMessageMatches('/A collision was detected between the following constructors of the class `stdClass`: `Closure .*`, `Closure .*`\./');
(new MapperBuilder())
->registerConstructor(
fn (string $foo): stdClass => new stdClass(),
fn (): stdClass => new stdClass(),
fn (): stdClass => new stdClass(),
)
->mapper()
->map(stdClass::class, []);
}
public function test_identical_registered_constructors_with_one_argument_throws_exception(): void
{
$this->expectException(ObjectBuildersCollision::class);
$this->expectExceptionCode(1654955787);
$this->expectExceptionMessageMatches('/A collision was detected between the following constructors of the class `stdClass`: `Closure .*`, `Closure .*`\./');
(new MapperBuilder())
->registerConstructor(
fn (int $int): stdClass => new stdClass(),
fn (float $float): stdClass => new stdClass(),
)
->mapper()
->map(stdClass::class, []);
}
public function test_identical_registered_constructors_with_several_argument_throws_exception(): void
{
$this->expectException(ObjectBuildersCollision::class);
$this->expectExceptionCode(1654955787);
$this->expectExceptionMessage('A collision was detected between the following constructors of the class `stdClass`: `CuyZ\Valinor\Tests\Integration\Mapping\constructorA()`, `CuyZ\Valinor\Tests\Integration\Mapping\constructorB()`.');
(new MapperBuilder())
->registerConstructor(
__NAMESPACE__ . '\constructorA', // @PHP8.1 First-class callable syntax
fn (int $other, float $arguments): stdClass => new stdClass(),
__NAMESPACE__ . '\constructorB', // @PHP8.1 First-class callable syntax
)
->mapper()
->map(stdClass::class, []);
}
public function test_source_not_matching_registered_constructors_throws_exception(): void
{
try {
(new MapperBuilder())
->registerConstructor(
fn (int $bar, float $baz = 1337.404): stdClass => new stdClass(),
fn (string $foo): stdClass => new stdClass(),
)
->mapper()
->map(stdClass::class, []);
} catch (MappingError $exception) {
$error = $exception->node()->messages()[0];
self::assertSame('1642183169', $error->code());
self::assertSame('Value array (empty) does not match any of `string`, `array{bar: int, baz?: float}`.', (string)$error);
}
}
public function test_non_registered_named_constructors_are_ignored(): void
{
$this->expectException(CannotInstantiateObject::class);
$this->expectExceptionCode(1646916477);
$this->expectExceptionMessage('No available constructor found for class `' . SomeClassWithPrivateNativeConstructor::class . '`');
(new MapperBuilder())
->mapper()
->map(SomeClassWithPrivateNativeConstructor::class, []);
}
public function test_registered_datetime_constructor_is_used(): void
{
$default = new DateTime('@1356097062');
$defaultImmutable = new DateTimeImmutable('@1356097062');
$mapper = (new MapperBuilder())
->registerConstructor(fn (int $timestamp): DateTime => $default)
->registerConstructor(fn (int $timestamp): DateTimeImmutable => $defaultImmutable)
->mapper();
try {
$result = $mapper->map(DateTime::class, 1357047105);
$resultImmutable = $mapper->map(DateTimeImmutable::class, 1357047105);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($default, $result);
self::assertSame($defaultImmutable, $resultImmutable);
}
public function test_registered_datetime_constructor_not_matching_source_uses_default_constructor(): void
{
try {
$result = (new MapperBuilder())
->registerConstructor(fn (string $foo, int $bar): DateTimeInterface => new DateTimeImmutable())
->mapper()
->map(DateTimeInterface::class, 1647781015);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertEquals(new DateTimeImmutable('@1647781015'), $result);
}
public function test_registered_constructor_throwing_exception_fails_mapping_with_message(): void
{
try {
(new MapperBuilder())
->registerConstructor(function (): stdClass {
// @PHP8.0 use short closure
throw new FakeErrorMessage('some error message', 1656076090);
})
->mapper()
->map(stdClass::class, []);
self::fail('No mapping error when one was expected');
} catch (MappingError $exception) {
$error = $exception->node()->messages()[0];
self::assertSame('1656076090', $error->code());
self::assertSame('some error message', (string)$error);
}
}
public function test_deprecated_bind_function_to_registered_constructor_is_used(): void
{
$object = new stdClass();
try {
$result = (new MapperBuilder())
->bind(fn (): stdClass => $object)
->mapper()
->map(stdClass::class, []);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($object, $result);
}
}
final class SomeClassWithNamedConstructors
{
public string $foo;
public static function namedConstructor(string $foo): self
{
$instance = new self();
$instance->foo = $foo;
return $instance;
}
}
final class SomeClassWithSimilarNativeConstructorAndNamedConstructor
{
public string $foo;
public function __construct(string $foo)
{
$this->foo = $foo;
}
public static function namedConstructor(string $foo): self
{
$instance = new self($foo);
$instance->foo = 'value from named constructor';
return $instance;
}
}
final class SomeClassWithDifferentNativeConstructorAndNamedConstructor
{
public string $foo;
public int $bar;
public function __construct(string $foo, int $bar)
{
$this->foo = $foo;
$this->bar = $bar;
}
public static function namedConstructor(string $foo): self
{
return new self($foo, 42);
}
}
final class SomeClassWithPrivateNativeConstructor
{
public string $foo;
private function __construct(string $foo)
{
$this->foo = $foo;
}
public static function namedConstructorWithNoParameter(): self
{
return new self('foo');
}
public static function namedConstructorWithParameter(string $foo): self
{
return new self($foo);
}
}
function constructorA(int $argumentA, float $argumentB): stdClass
{
return new stdClass();
}
function constructorB(int $argumentA, float $argumentB): stdClass
{
return new stdClass();
}