50 - Stamp authenticated user as Scenario owner on write
This commit is contained in:
parent
468f60da7f
commit
e4b1506ac5
14 changed files with 417 additions and 16 deletions
33
migrations/Version20260623183021.php
Normal file
33
migrations/Version20260623183021.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260623183021 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE scenario ADD owner_id UUID NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE scenario ADD CONSTRAINT FK_3E45C8D87E3C61F9 FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_3E45C8D87E3C61F9 ON scenario (owner_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE scenario DROP CONSTRAINT FK_3E45C8D87E3C61F9');
|
||||||
|
$this->addSql('DROP INDEX IDX_3E45C8D87E3C61F9');
|
||||||
|
$this->addSql('ALTER TABLE scenario DROP owner_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,15 @@ class Scenario
|
||||||
#[ORM\Column(type: 'uuid')]
|
#[ORM\Column(type: 'uuid')]
|
||||||
private UuidV7 $id;
|
private UuidV7 $id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set server-side by App\State\ScenarioOwnerProcessor on write; must be
|
||||||
|
* assigned via setOwner() before persist. The non-nullable type means
|
||||||
|
* getOwner() throws if read before that invariant is satisfied.
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private User $owner;
|
||||||
|
|
||||||
#[ORM\Column(enumType: DistributionMode::class)]
|
#[ORM\Column(enumType: DistributionMode::class)]
|
||||||
private DistributionMode $distributionMode = DistributionMode::EVEN;
|
private DistributionMode $distributionMode = DistributionMode::EVEN;
|
||||||
|
|
||||||
|
|
@ -44,6 +53,11 @@ class Scenario
|
||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getOwner(): User
|
||||||
|
{
|
||||||
|
return $this->owner;
|
||||||
|
}
|
||||||
|
|
||||||
public function getDistributionMode(): DistributionMode
|
public function getDistributionMode(): DistributionMode
|
||||||
{
|
{
|
||||||
return $this->distributionMode;
|
return $this->distributionMode;
|
||||||
|
|
@ -59,6 +73,13 @@ class Scenario
|
||||||
return $this->description;
|
return $this->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setOwner(User $owner): static
|
||||||
|
{
|
||||||
|
$this->owner = $owner;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function setDistributionMode(DistributionMode $distributionMode): static
|
public function setDistributionMode(DistributionMode $distributionMode): static
|
||||||
{
|
{
|
||||||
$this->distributionMode = $distributionMode;
|
$this->distributionMode = $distributionMode;
|
||||||
|
|
|
||||||
50
src/State/ScenarioOwnerProcessor.php
Normal file
50
src/State/ScenarioOwnerProcessor.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps the authenticated user as the owner of a Scenario on write,
|
||||||
|
* then hands off to the core Doctrine persist processor.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<object, object|null>
|
||||||
|
*/
|
||||||
|
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
final class ScenarioOwnerProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ProcessorInterface<object, object|null> $decorated
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
#[AutowireDecorated]
|
||||||
|
private readonly ProcessorInterface $decorated,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = [],
|
||||||
|
): mixed {
|
||||||
|
if ($data instanceof Scenario) {
|
||||||
|
// The Scenario Post operation is gated by `is_granted('ROLE_USER')`
|
||||||
|
// (see #[ApiResource] on App\Entity\Scenario), so by the time this
|
||||||
|
// processor runs the security pipeline guarantees a logged-in User.
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
\assert($user instanceof User);
|
||||||
|
|
||||||
|
$data->setOwner($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Tests\Entity;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
|
@ -21,10 +22,25 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistOwner(): User
|
||||||
|
{
|
||||||
|
static $counter = 0;
|
||||||
|
++$counter;
|
||||||
|
|
||||||
|
$owner = new User();
|
||||||
|
$owner->setEmail(\sprintf('bucket-persistence-test-%d@example.com', $counter));
|
||||||
|
$owner->setPassword('irrelevant-hash');
|
||||||
|
|
||||||
|
$this->em->persist($owner);
|
||||||
|
|
||||||
|
return $owner;
|
||||||
|
}
|
||||||
|
|
||||||
public function testItPersistsABucketLinkedToItsScenario(): void
|
public function testItPersistsABucketLinkedToItsScenario(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -95,6 +111,7 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -134,6 +151,7 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -174,6 +192,7 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
49
tests/Entity/ScenarioOwnerTest.php
Normal file
49
tests/Entity/ScenarioOwnerTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
final class ScenarioOwnerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOwnerRoundTripsThroughPersistence(): void
|
||||||
|
{
|
||||||
|
$owner = new User();
|
||||||
|
$owner->setEmail('scenario-owner-test@example.com');
|
||||||
|
$owner->setPassword('irrelevant-hash');
|
||||||
|
|
||||||
|
$this->em->persist($owner);
|
||||||
|
|
||||||
|
$scenario = new Scenario();
|
||||||
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($owner);
|
||||||
|
|
||||||
|
$this->em->persist($scenario);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$scenarioId = $scenario->getId();
|
||||||
|
$ownerId = $owner->getId();
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$reloaded = $this->em->find(Scenario::class, $scenarioId);
|
||||||
|
|
||||||
|
self::assertNotNull($reloaded, 'Scenario must be retrievable from the database after an identity-map clear.');
|
||||||
|
self::assertInstanceOf(User::class, $reloaded->getOwner());
|
||||||
|
self::assertTrue(
|
||||||
|
$ownerId->equals($reloaded->getOwner()->getId()),
|
||||||
|
'The owner set before persistence must survive a flush + clear + reload round-trip.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Tests\Entity;
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\DistributionMode;
|
use App\Enum\DistributionMode;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
@ -18,10 +19,25 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistOwner(): User
|
||||||
|
{
|
||||||
|
static $counter = 0;
|
||||||
|
++$counter;
|
||||||
|
|
||||||
|
$owner = new User();
|
||||||
|
$owner->setEmail(\sprintf('scenario-persistence-test-%d@example.com', $counter));
|
||||||
|
$owner->setPassword('irrelevant-hash');
|
||||||
|
|
||||||
|
$this->em->persist($owner);
|
||||||
|
|
||||||
|
return $owner;
|
||||||
|
}
|
||||||
|
|
||||||
public function testItPersistsAScenarioWithAUuidId(): void
|
public function testItPersistsAScenarioWithAUuidId(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -42,6 +58,7 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Default Mode Scenario');
|
$scenario->setName('Default Mode Scenario');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -65,6 +82,7 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Priority Mode Scenario');
|
$scenario->setName('Priority Mode Scenario');
|
||||||
$scenario->setDistributionMode(DistributionMode::PRIORITY);
|
$scenario->setDistributionMode(DistributionMode::PRIORITY);
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -93,6 +111,7 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Described Scenario');
|
$scenario->setName('Described Scenario');
|
||||||
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
|
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -111,6 +130,7 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Undescribed Scenario');
|
$scenario->setName('Undescribed Scenario');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Tests\Entity;
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\Stream;
|
use App\Entity\Stream;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Enum\StreamFrequency;
|
use App\Enum\StreamFrequency;
|
||||||
|
|
@ -22,10 +23,25 @@ final class StreamPersistenceTest extends KernelTestCase
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistOwner(): User
|
||||||
|
{
|
||||||
|
static $counter = 0;
|
||||||
|
++$counter;
|
||||||
|
|
||||||
|
$owner = new User();
|
||||||
|
$owner->setEmail(\sprintf('stream-persistence-test-%d@example.com', $counter));
|
||||||
|
$owner->setPassword('irrelevant-hash');
|
||||||
|
|
||||||
|
$this->em->persist($owner);
|
||||||
|
|
||||||
|
return $owner;
|
||||||
|
}
|
||||||
|
|
||||||
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
|
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
|
|
||||||
$bucket = new Bucket();
|
$bucket = new Bucket();
|
||||||
|
|
@ -119,6 +135,7 @@ final class StreamPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ final class BucketApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -36,11 +37,11 @@ final class BucketApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$user = new User();
|
$this->user = new User();
|
||||||
$user->setEmail(self::EMAIL);
|
$this->user->setEmail(self::EMAIL);
|
||||||
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($user);
|
$this->em->persist($this->user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +63,7 @@ final class BucketApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
|
$scenario->setOwner($this->user);
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -41,11 +42,11 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$user = new User();
|
$this->user = new User();
|
||||||
$user->setEmail(self::EMAIL);
|
$this->user->setEmail(self::EMAIL);
|
||||||
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($user);
|
$this->em->persist($this->user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +68,7 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
|
$scenario->setOwner($this->user);
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -32,11 +33,11 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$user = new User();
|
$this->user = new User();
|
||||||
$user->setEmail(self::EMAIL);
|
$this->user->setEmail(self::EMAIL);
|
||||||
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($user);
|
$this->em->persist($this->user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +59,7 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
|
$scenario->setOwner($this->user);
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
156
tests/Functional/ScenarioOwnerApiTest.php
Normal file
156
tests/Functional/ScenarioOwnerApiTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||||
|
|
||||||
|
final class ScenarioOwnerApiTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const EMAIL = 'scenario-owner-api-test@example.com';
|
||||||
|
private const PASSWORD = 'correct-horse-battery-staple';
|
||||||
|
|
||||||
|
private const OTHER_EMAIL = 'scenario-owner-api-spoof-target@example.com';
|
||||||
|
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $caller;
|
||||||
|
private User $otherUser;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = static::createClient();
|
||||||
|
|
||||||
|
/** @var EntityManagerInterface $em */
|
||||||
|
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->em = $em;
|
||||||
|
|
||||||
|
$factory = new PasswordHasherFactory([
|
||||||
|
User::class => new NativePasswordHasher(cost: 4),
|
||||||
|
]);
|
||||||
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
|
$this->caller = new User();
|
||||||
|
$this->caller->setEmail(self::EMAIL);
|
||||||
|
$this->caller->setPassword($hasher->hashPassword($this->caller, self::PASSWORD));
|
||||||
|
$this->em->persist($this->caller);
|
||||||
|
|
||||||
|
$this->otherUser = new User();
|
||||||
|
$this->otherUser->setEmail(self::OTHER_EMAIL);
|
||||||
|
$this->otherUser->setPassword($hasher->hashPassword($this->otherUser, 'some-other-password'));
|
||||||
|
$this->em->persist($this->otherUser);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function login(): void
|
||||||
|
{
|
||||||
|
$this->client->jsonRequest('POST', '/api/login', [
|
||||||
|
'username' => self::EMAIL,
|
||||||
|
'password' => self::PASSWORD,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertLessThan(
|
||||||
|
300,
|
||||||
|
$this->client->getResponse()->getStatusCode(),
|
||||||
|
'Test setup precondition: login must succeed before exercising the Scenario API.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostCreatesAScenarioOwnedByTheAuthenticatedUser(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/scenarios',
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
],
|
||||||
|
content: json_encode(['name' => 'Holiday Fund']),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
201,
|
||||||
|
$this->client->getResponse()->getStatusCode(),
|
||||||
|
'POST /api/scenarios with a valid body must return 201 Created.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$persisted = $this->em->getRepository(\App\Entity\Scenario::class)->findOneBy(['name' => 'Holiday Fund']);
|
||||||
|
|
||||||
|
self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.');
|
||||||
|
self::assertNotNull($persisted->getOwner(), 'The persisted Scenario must have an owner set.');
|
||||||
|
self::assertTrue(
|
||||||
|
$this->caller->getId()->equals($persisted->getOwner()->getId()),
|
||||||
|
'The persisted Scenario must be owned by the authenticated caller, not left null or assigned elsewhere.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostIgnoresAClientSuppliedOwner(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/scenarios',
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
],
|
||||||
|
content: json_encode([
|
||||||
|
'name' => 'Spoofed Owner Fund',
|
||||||
|
'owner' => '/api/users/'.$this->otherUser->getId(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
201,
|
||||||
|
$this->client->getResponse()->getStatusCode(),
|
||||||
|
'POST /api/scenarios with a valid body must return 201 Created even when a client-supplied owner is present in the payload.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$persisted = $this->em->getRepository(\App\Entity\Scenario::class)->findOneBy(['name' => 'Spoofed Owner Fund']);
|
||||||
|
|
||||||
|
self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.');
|
||||||
|
self::assertNotNull($persisted->getOwner(), 'The persisted Scenario must have an owner set.');
|
||||||
|
self::assertTrue(
|
||||||
|
$this->caller->getId()->equals($persisted->getOwner()->getId()),
|
||||||
|
'A client-supplied owner in the request body must be ignored — the owner must always be the authenticated caller.',
|
||||||
|
);
|
||||||
|
self::assertFalse(
|
||||||
|
$this->otherUser->getId()->equals($persisted->getOwner()->getId()),
|
||||||
|
'The spoofed owner IRI in the request body must never be honoured.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostWithMissingNameIsStillRejectedAsUnprocessable(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/scenarios',
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
],
|
||||||
|
content: json_encode([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
422,
|
||||||
|
$this->client->getResponse()->getStatusCode(),
|
||||||
|
'POST /api/scenarios without a name must still return 422 Unprocessable Entity — the owner-setting persist processor must not interfere with existing validation order.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ final class StreamApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -36,11 +37,11 @@ final class StreamApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$user = new User();
|
$this->user = new User();
|
||||||
$user->setEmail(self::EMAIL);
|
$this->user->setEmail(self::EMAIL);
|
||||||
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($user);
|
$this->em->persist($this->user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +63,7 @@ final class StreamApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
|
$scenario->setOwner($this->user);
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Tests\Repository;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Repository\BucketRepository;
|
use App\Repository\BucketRepository;
|
||||||
|
|
@ -141,8 +142,17 @@ final class BucketRepositoryTest extends KernelTestCase
|
||||||
|
|
||||||
private function persistScenario(string $name = 'Household Budget'): Scenario
|
private function persistScenario(string $name = 'Household Budget'): Scenario
|
||||||
{
|
{
|
||||||
|
static $counter = 0;
|
||||||
|
++$counter;
|
||||||
|
|
||||||
|
$owner = new User();
|
||||||
|
$owner->setEmail(\sprintf('bucket-repository-test-%d@example.com', $counter));
|
||||||
|
$owner->setPassword('irrelevant-hash');
|
||||||
|
$this->em->persist($owner);
|
||||||
|
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
|
$scenario->setOwner($owner);
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Tests\Validator;
|
||||||
|
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Validator\SingleOverflowPerScenario;
|
use App\Validator\SingleOverflowPerScenario;
|
||||||
|
|
@ -24,10 +25,25 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
$this->validator = self::getContainer()->get(ValidatorInterface::class);
|
$this->validator = self::getContainer()->get(ValidatorInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistOwner(): User
|
||||||
|
{
|
||||||
|
static $counter = 0;
|
||||||
|
++$counter;
|
||||||
|
|
||||||
|
$owner = new User();
|
||||||
|
$owner->setEmail(\sprintf('single-overflow-per-scenario-test-%d@example.com', $counter));
|
||||||
|
$owner->setPassword('irrelevant-hash');
|
||||||
|
|
||||||
|
$this->em->persist($owner);
|
||||||
|
|
||||||
|
return $owner;
|
||||||
|
}
|
||||||
|
|
||||||
public function testSecondOverflowBucketOnTheSameScenarioRaisesAViolation(): void
|
public function testSecondOverflowBucketOnTheSameScenarioRaisesAViolation(): void
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -54,6 +70,7 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -77,6 +94,7 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue