feat: address ownership checks and default address (#262)

This commit is contained in:
Pep Villalonga Amorós
2026-11-04 16:53:45 +01:00
committed by GitHub
parent 7a8efca486
commit a82b9dec37
10 changed files with 288 additions and 29 deletions
+26
View File
@@ -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');
}
}
+9 -2
View File
@@ -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;
}
+6
View File
@@ -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;
}
+6
View File
@@ -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;
}
+15
View File
@@ -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;
}
}
+22 -23
View File
@@ -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();
}
}
+13 -2
View File
@@ -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
),
]));
}
}
+116
View File
@@ -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()));
}
}