feat: address ownership checks and default address (#262)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261026131134 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add default flag to addresses';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE address ADD is_default TINYINT(1) DEFAULT 0 NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE address DROP is_default');
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,12 @@ use App\State\ApiResourceStateProvider;
|
||||
input: AddressCreationDto::class,
|
||||
securityPostDenormalize: 'is_granted("USER_EDIT", object.user)'
|
||||
)]
|
||||
#[API\Get]
|
||||
#[API\Get(security: 'is_granted("ADDRESS_VIEW", object)')]
|
||||
#[API\Patch(
|
||||
input: AddressUpdationDto::class,
|
||||
securityPostDenormalize: 'is_granted("ADDRESS_EDIT", previous_object)'
|
||||
)]
|
||||
#[API\Delete()]
|
||||
#[API\Delete(security: 'is_granted("ADDRESS_EDIT", object)')]
|
||||
class AddressApiResource
|
||||
{
|
||||
#[API\ApiProperty(identifier: true, writable: false)]
|
||||
@@ -87,4 +87,11 @@ class AddressApiResource
|
||||
#[API\ApiProperty(security: 'is_granted("ADDRESS_VIEW", object)')]
|
||||
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
|
||||
public string $country;
|
||||
|
||||
/**
|
||||
* Whether this is the User's default Address. Each User has at most one default Address,
|
||||
* setting it to `true` on one Address unsets it on the rest of the User's Addresses.
|
||||
*/
|
||||
#[API\ApiProperty(security: 'is_granted("ADDRESS_VIEW", object)')]
|
||||
public bool $isDefault = false;
|
||||
}
|
||||
|
||||
@@ -60,4 +60,10 @@ class AddressCreationDto
|
||||
#[Assert\NotBlank()]
|
||||
#[Assert\Country()]
|
||||
public string $country;
|
||||
|
||||
/**
|
||||
* Whether this is the User's default Address.
|
||||
* Setting it to `true` unsets it on the rest of the User's Addresses.
|
||||
*/
|
||||
public ?bool $isDefault = null;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,10 @@ class AddressUpdationDto
|
||||
*/
|
||||
#[Assert\Country()]
|
||||
public ?string $country = null;
|
||||
|
||||
/**
|
||||
* Whether this is the User's default Address.
|
||||
* Setting it to `true` unsets it on the rest of the User's Addresses.
|
||||
*/
|
||||
public ?bool $isDefault = null;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ class Address
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
private bool $isDefault = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -142,4 +145,16 @@ class Address
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->isDefault;
|
||||
}
|
||||
|
||||
public function setIsDefault(bool $isDefault): static
|
||||
{
|
||||
$this->isDefault = $isDefault;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Address;
|
||||
use App\Entity\User\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -16,28 +17,26 @@ class AddressRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, Address::class);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return Address[] Returns an array of Address objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('a.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
/**
|
||||
* Unsets the default flag on every Address of the given User except the given one.
|
||||
*/
|
||||
public function unsetDefaultsOfUser(User $user, ?Address $except): void
|
||||
{
|
||||
$query = $this->createQueryBuilder('a')
|
||||
->update()
|
||||
->set('a.isDefault', ':false')
|
||||
->where('a.user = :user')
|
||||
->setParameter('false', false)
|
||||
->setParameter('user', $user)
|
||||
;
|
||||
|
||||
// public function findOneBySomeField($value): ?Address
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
if ($except?->getId() !== null) {
|
||||
$query
|
||||
->andWhere('a.id != :except')
|
||||
->setParameter('except', $except->getId())
|
||||
;
|
||||
}
|
||||
|
||||
$query->getQuery()->execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,20 @@ use App\Dto\AddressCreationDto;
|
||||
use App\Dto\AddressUpdationDto;
|
||||
use App\Entity\Address;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Repository\AddressRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class AddressStateProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private AutoMapper $autoMapper,
|
||||
private EntityStateProcessor $entityStateProcessor,
|
||||
private AddressRepository $addressRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param AddressCreationDto|AddressUpdationDto $data
|
||||
* @param AddressCreationDto|AddressUpdationDto|AddressApiResource $data
|
||||
*
|
||||
* @return AddressApiResource
|
||||
*/
|
||||
@@ -28,10 +32,17 @@ class AddressStateProcessor implements ProcessorInterface
|
||||
$address = match ($data::class) {
|
||||
AddressCreationDto::class => $this->getAddressFromCreation($data),
|
||||
AddressUpdationDto::class => $this->getAddressFromUpdate($data, $context),
|
||||
AddressApiResource::class => $this->autoMapper->map($data, Address::class),
|
||||
default => throw new \InvalidArgumentException('Unsupported input for Address resource'),
|
||||
};
|
||||
|
||||
$address = $this->entityStateProcessor->process($address, $operation, $uriVariables, $context);
|
||||
$address = $this->entityManager->wrapInTransaction(function () use ($address, $operation, $uriVariables, $context) {
|
||||
if ($address->isDefault()) {
|
||||
$this->addressRepository->unsetDefaultsOfUser($address->getUser(), $address);
|
||||
}
|
||||
|
||||
return $this->entityStateProcessor->process($address, $operation, $uriVariables, $context);
|
||||
});
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return;
|
||||
|
||||
@@ -5,10 +5,13 @@ namespace App\State\Project;
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\ApiResource\Project\RewardClaimApiResource;
|
||||
use App\Dto\RewardClaimCreationDto;
|
||||
use App\Dto\RewardClaimUpdationDto;
|
||||
use App\Entity\Address;
|
||||
use App\Entity\Project\RewardClaim;
|
||||
use App\Entity\User\User;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Repository\Project\RewardClaimRepository;
|
||||
use App\Security\Voter\AccountingVoter;
|
||||
@@ -16,9 +19,13 @@ use App\State\EntityStateProcessor;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
class RewardClaimStateProcessor implements ProcessorInterface
|
||||
{
|
||||
private const ADDRESS_NOT_OWNED_MESSAGE = 'The Address does not belong to the User who owns the claim.';
|
||||
|
||||
public function __construct(
|
||||
private AutoMapper $autoMapper,
|
||||
private RewardClaimRepository $rewardClaimRepository,
|
||||
@@ -34,7 +41,7 @@ class RewardClaimStateProcessor implements ProcessorInterface
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
$claim = match ($data::class) {
|
||||
RewardClaimUpdationDto::class => $this->getClaimFromUpdate($context),
|
||||
RewardClaimUpdationDto::class => $this->getClaimFromUpdate($data, $context),
|
||||
RewardClaimCreationDto::class => $this->getClaimFromCreate($data),
|
||||
};
|
||||
|
||||
@@ -51,13 +58,28 @@ class RewardClaimStateProcessor implements ProcessorInterface
|
||||
return $this->autoMapper->map($claim, $data);
|
||||
}
|
||||
|
||||
private function getClaimFromUpdate(array $context): RewardClaim
|
||||
/**
|
||||
* An Address can only be used in a claim by the User who owns that claim.
|
||||
*/
|
||||
public static function isAddressOwnedBy(?Address $address, ?User $owner): bool
|
||||
{
|
||||
return $address === null || $address->getUser() === $owner;
|
||||
}
|
||||
|
||||
private function getClaimFromUpdate(RewardClaimUpdationDto $data, array $context): RewardClaim
|
||||
{
|
||||
$claim = $this->rewardClaimRepository->find($context['previous_data']->id);
|
||||
if (!$claim) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
if (isset($data->address)) {
|
||||
/** @var Address */
|
||||
$address = $this->autoMapper->map($data->address, Address::class);
|
||||
|
||||
$this->validateAddressOwner($address, $claim->getOwner(), $data);
|
||||
}
|
||||
|
||||
return $claim;
|
||||
}
|
||||
|
||||
@@ -73,8 +95,28 @@ class RewardClaimStateProcessor implements ProcessorInterface
|
||||
|
||||
$owner = $origin->getUser();
|
||||
|
||||
$this->validateAddressOwner($claim->getAddress(), $owner, $data);
|
||||
|
||||
$claim->setOwner($owner);
|
||||
|
||||
return $claim;
|
||||
}
|
||||
|
||||
private function validateAddressOwner(?Address $address, ?User $owner, object $data): void
|
||||
{
|
||||
if (self::isAddressOwnedBy($address, $owner)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ValidationException(new ConstraintViolationList([
|
||||
new ConstraintViolation(
|
||||
self::ADDRESS_NOT_OWNED_MESSAGE,
|
||||
self::ADDRESS_NOT_OWNED_MESSAGE,
|
||||
[],
|
||||
$data,
|
||||
'address',
|
||||
$data->address
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Entity\Address;
|
||||
use App\Entity\User\User;
|
||||
use App\Factory\User\UserFactory;
|
||||
use App\Tests\Fixtures\TestUser;
|
||||
use App\Tests\Traits\RequestingTestTrait;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Zenstruck\Foundry\Test\Factories;
|
||||
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||
|
||||
class AddressApiTest extends ApiTestCase
|
||||
{
|
||||
use ResetDatabase;
|
||||
use Factories;
|
||||
use RequestingTestTrait;
|
||||
|
||||
private const BASE_URI = '/v4/addresses';
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
}
|
||||
|
||||
public function testDeleteOtherUserAddressIsForbidden(): void
|
||||
{
|
||||
$other = UserFactory::new(['handle' => 'other_user', 'email' => 'other@example.com'])->create()->_real();
|
||||
$address = $this->createAddress($other);
|
||||
|
||||
$this->request('DELETE', $this->getUri($address), [
|
||||
'headers' => $this->withAuthHeader(TestUser::get()),
|
||||
], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
public function testDeleteOwnAddress(): void
|
||||
{
|
||||
$user = TestUser::get();
|
||||
$address = $this->createAddress($user);
|
||||
|
||||
$this->request('DELETE', $this->getUri($address), [
|
||||
'headers' => $this->withAuthHeader($user),
|
||||
], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
public function testGetOtherUserAddressIsForbidden(): void
|
||||
{
|
||||
$other = UserFactory::new(['handle' => 'other_user', 'email' => 'other@example.com'])->create()->_real();
|
||||
$address = $this->createAddress($other);
|
||||
|
||||
$this->request('GET', $this->getUri($address), [
|
||||
'headers' => $this->withAuthHeader(TestUser::get()),
|
||||
], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
public function testSetDefaultUnsetsOtherDefaultsOfUser(): void
|
||||
{
|
||||
$user = TestUser::get();
|
||||
$userId = $user->getId();
|
||||
$auth = $this->withAuthHeader($user);
|
||||
$other = UserFactory::new(['handle' => 'other_user', 'email' => 'other@example.com'])->create()->_real();
|
||||
|
||||
$previous = $this->createAddress($user, true);
|
||||
$address = $this->createAddress($user);
|
||||
$othersDefault = $this->createAddress($other, true);
|
||||
|
||||
$response = $this->request('PATCH', $this->getUri($address), [
|
||||
'headers' => $auth,
|
||||
'json' => ['isDefault' => true],
|
||||
], Response::HTTP_OK);
|
||||
|
||||
$this->assertTrue($response->toArray()['isDefault']);
|
||||
|
||||
$response = $this->request('GET', \sprintf('%s?user=/v4/users/%d', self::BASE_URI, $userId), [
|
||||
'headers' => $auth,
|
||||
], Response::HTTP_OK);
|
||||
|
||||
$defaults = \array_column($response->toArray()['member'], 'isDefault', 'id');
|
||||
$this->assertEquals([$previous->getId() => false, $address->getId() => true], $defaults);
|
||||
|
||||
$this->getEntityManager()->clear();
|
||||
$this->assertTrue($this->getEntityManager()->find(Address::class, $othersDefault->getId())->isDefault());
|
||||
}
|
||||
|
||||
private function createAddress(User $user, bool $isDefault = false): Address
|
||||
{
|
||||
$address = (new Address())
|
||||
->setUser($this->getEntityManager()->getReference(User::class, $user->getId()))
|
||||
->setFirstName('Test')
|
||||
->setLastName('User')
|
||||
->setLine1('Carrer Major 1')
|
||||
->setCity('Barcelona')
|
||||
->setPostCode('08001')
|
||||
->setCountry('ES')
|
||||
->setIsDefault($isDefault)
|
||||
;
|
||||
|
||||
$this->getEntityManager()->persist($address);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
private function getUri(Address $address): string
|
||||
{
|
||||
return \sprintf('%s/%d', self::BASE_URI, $address->getId());
|
||||
}
|
||||
|
||||
private function getEntityManager(): EntityManagerInterface
|
||||
{
|
||||
return static::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Entity\Address;
|
||||
use App\Entity\User\User;
|
||||
use App\State\Project\RewardClaimStateProcessor;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RewardClaimAddressOwnerTest extends TestCase
|
||||
{
|
||||
public function testNullAddressIsValid(): void
|
||||
{
|
||||
$this->assertTrue(RewardClaimStateProcessor::isAddressOwnedBy(null, new User()));
|
||||
}
|
||||
|
||||
public function testAddressOfClaimOwnerIsValid(): void
|
||||
{
|
||||
$owner = new User();
|
||||
$address = (new Address())->setUser($owner);
|
||||
|
||||
$this->assertTrue(RewardClaimStateProcessor::isAddressOwnedBy($address, $owner));
|
||||
}
|
||||
|
||||
public function testAddressOfOtherUserIsNotValid(): void
|
||||
{
|
||||
$address = (new Address())->setUser(new User());
|
||||
|
||||
$this->assertFalse(RewardClaimStateProcessor::isAddressOwnedBy($address, new User()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user