1
0
Fork 0
mirror of synced 2025-04-05 06:03:34 +03:00

add authenticators, tests

This commit is contained in:
Akolzin Dmitry 2021-02-04 18:06:23 +03:00
parent 22e03941b9
commit b6c6dfac2d
17 changed files with 851 additions and 5 deletions

View file

@ -2,8 +2,8 @@
namespace RetailCrm\ServiceBundle\ArgumentResolver;
use RetailCrm\ServiceBundle\Exceptions\InvalidRequestArgumentException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use InvalidArgumentException;
abstract class AbstractValueResolver
{
@ -21,7 +21,11 @@ abstract class AbstractValueResolver
{
$errors = $this->validator->validate($data);
if (0 !== count($errors)) {
throw new InvalidArgumentException($errors);
throw new InvalidRequestArgumentException(
sprintf("Invalid request parameter %s", \get_class($data)),
400,
$errors
);
}
}
}

View file

@ -49,10 +49,16 @@ class CallbackValueResolver extends AbstractValueResolver implements ArgumentVal
yield $data;
}
/**
* @param Request $request
* @param ArgumentMetadata $argument
*
* @return string|null
*/
private function search(Request $request, ArgumentMetadata $argument): ?string
{
foreach ($this->requestSchema as $callback) {
if (!$argument->getName() === $callback['type']) {
if ($argument->getType() !== $callback['type']) {
continue;
}

View file

@ -0,0 +1,80 @@
<?php
namespace RetailCrm\ServiceBundle\ArgumentResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Generator;
class ClientValueResolver extends AbstractValueResolver implements ArgumentValueResolverInterface
{
private $serializer;
private $denormalizer;
private $requestSchema;
public function __construct(
ValidatorInterface $validator,
SerializerInterface $serializer,
DenormalizerInterface $denormalizer,
array $requestSchema
) {
parent::__construct($validator);
$this->serializer = $serializer;
$this->denormalizer = $denormalizer;
$this->requestSchema = $requestSchema;
}
/**
* {@inheritdoc}
*/
public function supports(Request $request, ArgumentMetadata $argument): bool
{
return in_array($argument->getType(), $this->requestSchema, true);
}
/**
* {@inheritdoc}
*/
public function resolve(Request $request, ArgumentMetadata $argument): Generator
{
if (Request::METHOD_GET === $request->getMethod()) {
$dto = $this->handleGetData($request->query->all(), $argument->getType());
} else {
$dto = $this->handlePostData($request->getContent(), $argument->getType());
}
$this->validate($dto);
yield $dto;
}
/**
* @param array $data
* @param string $type
*
* @return object
*
* @throws ExceptionInterface
*/
private function handleGetData(array $data, string $type): object
{
return $this->denormalizer->denormalize($data, $type);
}
/**
* @param string $data
* @param string $type
*
* @return object
*/
private function handlePostData(string $data, string $type): object
{
return $this->serializer->deserialize($data, $type, 'json');
}
}

View file

@ -3,6 +3,10 @@
namespace RetailCrm\ServiceBundle\DependencyInjection;
use RetailCrm\ServiceBundle\ArgumentResolver\CallbackValueResolver;
use RetailCrm\ServiceBundle\ArgumentResolver\ClientValueResolver;
use RetailCrm\ServiceBundle\Response\ErrorJsonResponseFactory;
use RetailCrm\ServiceBundle\Security\CallbackClientAuthenticator;
use RetailCrm\ServiceBundle\Security\FrontApiClientAuthenticator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
@ -32,5 +36,23 @@ class RetailCrmServiceExtension extends Extension
->setArgument('$requestSchema', '%retail_crm_service.request_schema.callback%')
->addTag('controller.argument_value_resolver', ['priority' => 50])
->setAutowired(true);
$container
->register(ClientValueResolver::class)
->setArgument('$requestSchema', '%retail_crm_service.request_schema.client%')
->addTag('controller.argument_value_resolver', ['priority' => 50])
->setAutowired(true);
$container
->register(ErrorJsonResponseFactory::class)
->setAutowired(true);
$container
->register(CallbackClientAuthenticator::class)
->setAutowired(true);
$container
->register(FrontApiClientAuthenticator::class)
->setAutowired(true);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace RetailCrm\ServiceBundle\Exceptions;
use InvalidArgumentException;
use Throwable;
/**
* Class InvalidRequestArgumentException
*
* @package RetailCrm\ServiceBundle\Exceptions
*/
class InvalidRequestArgumentException extends InvalidArgumentException
{
private $validateErrors;
/**
* InvalidRequestArgumentException constructor.
* @param string $message
* @param int $code
* @param array $errors
* @param Throwable|null $previous
*/
public function __construct($message = "", $code = 0, iterable $errors = [], Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->validateErrors = $errors;
}
public function getValidateErrors(): iterable
{
return $this->validateErrors;
}
}

21
Models/Error.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace RetailCrm\ServiceBundle\Models;
class Error
{
/**
* @var string
*/
public $code;
/**
* @var string
*/
public $message;
/**
* @var array
*/
public $details;
}

View file

@ -0,0 +1,27 @@
<?php
namespace RetailCrm\ServiceBundle\Response;
use RetailCrm\ServiceBundle\Models\Error;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;
class ErrorJsonResponseFactory
{
private $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
public function create(Error $error, int $statusCode = Response::HTTP_BAD_REQUEST, array $headers = []): Response
{
return JsonResponse::fromJsonString(
$this->serializer->serialize($error, 'json'),
$statusCode,
$headers
);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace RetailCrm\ServiceBundle\Security;
use RetailCrm\ServiceBundle\Models\Error;
use RetailCrm\ServiceBundle\Response\ErrorJsonResponseFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
abstract class AbstractClientAuthenticator extends AbstractGuardAuthenticator
{
public const AUTH_FIELD = 'clientId';
private $errorResponseFactory;
public function __construct(ErrorJsonResponseFactory $errorResponseFactory)
{
$this->errorResponseFactory = $errorResponseFactory;
}
public function start(Request $request, AuthenticationException $authException = null): Response
{
$error = new Error();
$error->message = 'Authentication required';
return $this->errorResponseFactory->create($error,Response::HTTP_UNAUTHORIZED);
}
public function getCredentials(Request $request): string
{
return $request->get(static::AUTH_FIELD);
}
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
{
return $userProvider->loadUserByUsername($credentials);
}
public function checkCredentials($credentials, UserInterface $user): bool
{
return true;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$error = new Error();
$error->message = $exception->getMessageKey();
return $this->errorResponseFactory->create($error,Response::HTTP_FORBIDDEN);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response
{
return null;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace RetailCrm\ServiceBundle\Security;
use Symfony\Component\HttpFoundation\Request;
class CallbackClientAuthenticator extends AbstractClientAuthenticator
{
public function supports(Request $request): bool
{
return $request->request->has(static::AUTH_FIELD) || $request->query->has(static::AUTH_FIELD);
}
public function supportsRememberMe(): bool
{
return false;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace RetailCrm\ServiceBundle\Security;
use RetailCrm\ServiceBundle\Response\ErrorJsonResponseFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
class FrontApiClientAuthenticator extends AbstractClientAuthenticator
{
private $security;
public function __construct(
ErrorJsonResponseFactory $errorResponseFactory,
Security $security
) {
parent::__construct($errorResponseFactory);
$this->security = $security;
}
public function supports(Request $request): bool
{
if ($this->security->getUser()) {
return false;
}
return true;
}
public function supportsRememberMe(): bool
{
return true;
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace RetailCrm\ServiceBundle\Tests\ArgumentResolver;
use PHPUnit\Framework\TestCase;
use RetailCrm\ServiceBundle\ArgumentResolver\CallbackValueResolver;
use RetailCrm\ServiceBundle\Exceptions\InvalidRequestArgumentException;
use RetailCrm\ServiceBundle\Tests\DataFixtures\RequestDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validation;
use Generator;
class CallbackValueResolverTest extends TestCase
{
private $resolver;
public function setUp(): void
{
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$this->resolver = new CallbackValueResolver(
$serializer,
Validation::createValidatorBuilder()
->enableAnnotationMapping()
->getValidator(),
[
[
'type' => RequestDto::class,
'params' => ['request_parameter']
]
]
);
}
public function testSupports(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
[],
['request_parameter' => json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)],
[],
[],
[],
['REQUEST_METHOD' => Request::METHOD_POST]
);
$result = $this->resolver->supports($request, $argument);
static::assertTrue($result);
}
public function testNotSupports(): void
{
$argument = new ArgumentMetadata('RequestDto', 'NotFoundRequestDto', false, false, null);
$request = new Request(
[],
['request_parameter' => json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)],
[],
[],
[],
['REQUEST_METHOD' => Request::METHOD_POST]
);
$result = $this->resolver->supports($request, $argument);
static::assertFalse($result);
}
public function testResolve(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
[],
['request_parameter' => json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)],
[],
[],
[],
['REQUEST_METHOD' => Request::METHOD_POST]
);
$result = $this->resolver->resolve($request, $argument);
static::assertInstanceOf(Generator::class, $result);
static::assertInstanceOf(RequestDto::class, $result->current());
static::assertEquals('parameter', $result->current()->param);
}
public function testResolveFailure(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
[],
['request_parameter' => json_encode(['param' => null], JSON_THROW_ON_ERROR)],
[],
[],
[],
['REQUEST_METHOD' => Request::METHOD_POST]
);
$this->expectException(InvalidRequestArgumentException::class);
$result = $this->resolver->resolve($request, $argument);
$result->current();
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace RetailCrm\ServiceBundle\Tests\ArgumentResolver;
use PHPUnit\Framework\TestCase;
use RetailCrm\ServiceBundle\ArgumentResolver\ClientValueResolver;
use RetailCrm\ServiceBundle\Exceptions\InvalidRequestArgumentException;
use RetailCrm\ServiceBundle\Tests\DataFixtures\RequestDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validation;
use Generator;
class ClientValueResolverTest extends TestCase
{
private $resolver;
public function setUp(): void
{
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$this->resolver = new ClientValueResolver(
Validation::createValidatorBuilder()
->enableAnnotationMapping()
->getValidator(),
$serializer,
$serializer,
[
RequestDto::class
]
);
}
public function testSupports(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request();
$result = $this->resolver->supports($request, $argument);
static::assertTrue($result);
}
public function testNotSupports(): void
{
$argument = new ArgumentMetadata('RequestDto', 'NotFoundRequestDto', false, false, null);
$request = new Request();
$result = $this->resolver->supports($request, $argument);
static::assertFalse($result);
}
public function testResolvePost(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
[],
[],
[],
[],
[],
['REQUEST_METHOD' => Request::METHOD_POST],
json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)
);
$result = $this->resolver->resolve($request, $argument);
static::assertInstanceOf(Generator::class, $result);
static::assertInstanceOf(RequestDto::class, $result->current());
static::assertEquals('parameter', $result->current()->param);
}
public function testResolvePostFailure(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
[],
[],
[],
[],
[],
['REQUEST_METHOD' => Request::METHOD_POST],
json_encode(['param' => null], JSON_THROW_ON_ERROR)
);
$this->expectException(InvalidRequestArgumentException::class);
$result = $this->resolver->resolve($request, $argument);
$result->current();
}
public function testResolveGet(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
['param' => 'parameter'],
[],
[],
[],
[],
[]
);
$result = $this->resolver->resolve($request, $argument);
static::assertInstanceOf(Generator::class, $result);
static::assertInstanceOf(RequestDto::class, $result->current());
static::assertEquals('parameter', $result->current()->param);
}
public function testResolveGetFailure(): void
{
$argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null);
$request = new Request(
['param' => null],
[],
[],
[],
[],
[]
);
$this->expectException(InvalidRequestArgumentException::class);
$result = $this->resolver->resolve($request, $argument);
$result->current();
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace RetailCrm\ServiceBundle\Tests\DataFixtures;
use Symfony\Component\Validator\Constraints as Assert;
class RequestDto
{
/**
* @var string
* @Assert\NotNull()
*/
public $param;
}

View file

@ -0,0 +1,32 @@
<?php
namespace RetailCrm\ServiceBundle\Tests\DataFixtures;
use Symfony\Component\Security\Core\User\UserInterface;
class User implements UserInterface
{
public function getRoles(): array
{
return ["USER"];
}
public function getPassword(): string
{
return "123";
}
public function getSalt(): string
{
return "salt";
}
public function getUsername(): string
{
return "user";
}
public function eraseCredentials(): void
{
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace RetailCrm\ServiceBundle\Tests\Security;
use PHPUnit\Framework\TestCase;
use RetailCrm\ServiceBundle\Response\ErrorJsonResponseFactory;
use RetailCrm\ServiceBundle\Security\CallbackClientAuthenticator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
class CallbackClientAuthenticatorTest extends TestCase
{
public function testStart(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$errorResponseFactory
->expects(static::once())
->method('create')
->willReturn(
new JsonResponse(['message' => 'Authentication required'], Response::HTTP_UNAUTHORIZED)
);
$auth = new CallbackClientAuthenticator($errorResponseFactory);
$result = $auth->start(new Request(), new AuthenticationException());
static::assertInstanceOf(JsonResponse::class, $result);
static::assertEquals(Response::HTTP_UNAUTHORIZED, $result->getStatusCode());
}
public function testGetCredentials(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$auth = new CallbackClientAuthenticator($errorResponseFactory);
$result = $auth->getCredentials(new Request([], [CallbackClientAuthenticator::AUTH_FIELD => '123']));
static::assertEquals('123', $result);
$result = $auth->getCredentials(new Request([CallbackClientAuthenticator::AUTH_FIELD => '123']));
static::assertEquals('123', $result);
}
public function testCheckCredentials(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$user = new class implements UserInterface {
public function getRoles(): array
{
return ["USER"];
}
public function getPassword(): string
{
return "123";
}
public function getSalt(): string
{
return "salt";
}
public function getUsername(): string
{
return "user";
}
public function eraseCredentials(): void
{
}
};
$auth = new CallbackClientAuthenticator($errorResponseFactory);
$result = $auth->checkCredentials(new Request(), $user);
static::assertTrue($result);
}
public function testOnAuthenticationFailure(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$errorResponseFactory
->expects(static::once())
->method('create')
->willReturn(
new JsonResponse(
['message' => 'An authentication exception occurred.'],
Response::HTTP_FORBIDDEN
)
);
$auth = new CallbackClientAuthenticator($errorResponseFactory);
$result = $auth->start(new Request(), new AuthenticationException());
static::assertInstanceOf(JsonResponse::class, $result);
static::assertEquals(Response::HTTP_FORBIDDEN, $result->getStatusCode());
}
public function testSupports(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$auth = new CallbackClientAuthenticator($errorResponseFactory);
$result = $auth->supports(new Request([], [CallbackClientAuthenticator::AUTH_FIELD => '123']));
static::assertTrue($result);
$result = $auth->supports(new Request([CallbackClientAuthenticator::AUTH_FIELD => '123']));
static::assertTrue($result);
$result = $auth->supports(new Request());
static::assertFalse($result);
}
public function testSupportsRememberMe(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$auth = new CallbackClientAuthenticator($errorResponseFactory);
$result = $auth->supportsRememberMe();
static::assertFalse($result);
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace RetailCrm\ServiceBundle\Tests\Security;
use PHPUnit\Framework\TestCase;
use RetailCrm\ServiceBundle\Response\ErrorJsonResponseFactory;
use RetailCrm\ServiceBundle\Security\CallbackClientAuthenticator;
use RetailCrm\ServiceBundle\Security\FrontApiClientAuthenticator;
use RetailCrm\ServiceBundle\Tests\DataFixtures\User;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
class FrontApiClientAuthenticatorTest extends TestCase
{
public function testStart(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$errorResponseFactory
->expects(static::once())
->method('create')
->willReturn(
new JsonResponse(['message' => 'Authentication required'], Response::HTTP_UNAUTHORIZED)
);
$security = $this->createMock(Security::class);
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->start(new Request(), new AuthenticationException());
static::assertInstanceOf(JsonResponse::class, $result);
static::assertEquals(Response::HTTP_UNAUTHORIZED, $result->getStatusCode());
}
public function testGetCredentials(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$security = $this->createMock(Security::class);
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->getCredentials(new Request([], [CallbackClientAuthenticator::AUTH_FIELD => '123']));
static::assertEquals('123', $result);
$result = $auth->getCredentials(new Request([CallbackClientAuthenticator::AUTH_FIELD => '123']));
static::assertEquals('123', $result);
}
public function testCheckCredentials(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$security = $this->createMock(Security::class);
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->checkCredentials(new Request(), new User());
static::assertTrue($result);
}
public function testOnAuthenticationFailure(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$errorResponseFactory
->expects(static::once())
->method('create')
->willReturn(
new JsonResponse(
['message' => 'An authentication exception occurred.'],
Response::HTTP_FORBIDDEN
)
);
$security = $this->createMock(Security::class);
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->start(new Request(), new AuthenticationException());
static::assertInstanceOf(JsonResponse::class, $result);
static::assertEquals(Response::HTTP_FORBIDDEN, $result->getStatusCode());
}
public function testSupportsFalse(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(new User());
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->supports(new Request());
static::assertFalse($result);
}
public function testSupportsTrue(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(null);
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->supports(new Request());
static::assertTrue($result);
}
public function testSupportsRememberMe(): void
{
$errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class);
$security = $this->createMock(Security::class);
$auth = new FrontApiClientAuthenticator($errorResponseFactory, $security);
$result = $auth->supportsRememberMe();
static::assertTrue($result);
}
}

View file

@ -15,7 +15,8 @@
"symfony/framework-bundle": "^4.0|^5.0",
"symfony/serializer": "^5.2",
"symfony/http-kernel": "^4.0|^5.0",
"symfony/validator": "^4.0|^5.0"
"symfony/validator": "^4.0|^5.0",
"symfony/security-guard": "^4.0|^5.0"
},
"autoload": {
"psr-4": {
@ -31,7 +32,10 @@
}
},
"require-dev": {
"phpunit/phpunit": "^8.0 || ^9.0"
"ext-json": "*",
"phpunit/phpunit": "^8.0 || ^9.0",
"doctrine/annotations": "^1.11",
"doctrine/cache": "^1.10"
},
"scripts": {
"tests": "./vendor/bin/phpunit -c phpunit.xml.dist"