diff --git a/migrations/Version20260623183021.php b/migrations/Version20260623183021.php new file mode 100644 index 0000000..834c02e --- /dev/null +++ b/migrations/Version20260623183021.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/src/Entity/Scenario.php b/src/Entity/Scenario.php index 9d9abc1..adc33bc 100644 --- a/src/Entity/Scenario.php +++ b/src/Entity/Scenario.php @@ -24,6 +24,15 @@ class Scenario #[ORM\Column(type: 'uuid')] 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)] private DistributionMode $distributionMode = DistributionMode::EVEN; @@ -44,6 +53,11 @@ class Scenario return $this->id; } + public function getOwner(): User + { + return $this->owner; + } + public function getDistributionMode(): DistributionMode { return $this->distributionMode; @@ -59,6 +73,13 @@ class Scenario return $this->description; } + public function setOwner(User $owner): static + { + $this->owner = $owner; + + return $this; + } + public function setDistributionMode(DistributionMode $distributionMode): static { $this->distributionMode = $distributionMode; diff --git a/src/State/ScenarioOwnerProcessor.php b/src/State/ScenarioOwnerProcessor.php new file mode 100644 index 0000000..899ec9d --- /dev/null +++ b/src/State/ScenarioOwnerProcessor.php @@ -0,0 +1,50 @@ + + */ +#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')] +final class ScenarioOwnerProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $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); + } +} diff --git a/tests/Entity/BucketPersistenceTest.php b/tests/Entity/BucketPersistenceTest.php index da4cf94..ac7d0a8 100644 --- a/tests/Entity/BucketPersistenceTest.php +++ b/tests/Entity/BucketPersistenceTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Entity; use App\Entity\Bucket; use App\Entity\Scenario; +use App\Entity\User; use App\Enum\BucketAllocationType; use App\Enum\BucketType; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; @@ -21,10 +22,25 @@ final class BucketPersistenceTest extends KernelTestCase $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 { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -95,6 +111,7 @@ final class BucketPersistenceTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -134,6 +151,7 @@ final class BucketPersistenceTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -174,6 +192,7 @@ final class BucketPersistenceTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Entity/ScenarioOwnerTest.php b/tests/Entity/ScenarioOwnerTest.php new file mode 100644 index 0000000..ad05883 --- /dev/null +++ b/tests/Entity/ScenarioOwnerTest.php @@ -0,0 +1,49 @@ +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.', + ); + } +} diff --git a/tests/Entity/ScenarioPersistenceTest.php b/tests/Entity/ScenarioPersistenceTest.php index 3e56db4..3fee263 100644 --- a/tests/Entity/ScenarioPersistenceTest.php +++ b/tests/Entity/ScenarioPersistenceTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Entity; use App\Entity\Scenario; +use App\Entity\User; use App\Enum\DistributionMode; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -18,10 +19,25 @@ final class ScenarioPersistenceTest extends KernelTestCase $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 { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -42,6 +58,7 @@ final class ScenarioPersistenceTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Default Mode Scenario'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -65,6 +82,7 @@ final class ScenarioPersistenceTest extends KernelTestCase $scenario = new Scenario(); $scenario->setName('Priority Mode Scenario'); $scenario->setDistributionMode(DistributionMode::PRIORITY); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -93,6 +111,7 @@ final class ScenarioPersistenceTest extends KernelTestCase $scenario = new Scenario(); $scenario->setName('Described Scenario'); $scenario->setDescription('Covers rent, groceries, and the emergency buffer.'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -111,6 +130,7 @@ final class ScenarioPersistenceTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Undescribed Scenario'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Entity/StreamPersistenceTest.php b/tests/Entity/StreamPersistenceTest.php index c94dbb1..9a4c2ae 100644 --- a/tests/Entity/StreamPersistenceTest.php +++ b/tests/Entity/StreamPersistenceTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Entity; use App\Entity\Bucket; use App\Entity\Scenario; use App\Entity\Stream; +use App\Entity\User; use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Enum\StreamFrequency; @@ -22,10 +23,25 @@ final class StreamPersistenceTest extends KernelTestCase $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 { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $bucket = new Bucket(); @@ -119,6 +135,7 @@ final class StreamPersistenceTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Functional/BucketApiTest.php b/tests/Functional/BucketApiTest.php index 2eed148..33897f5 100644 --- a/tests/Functional/BucketApiTest.php +++ b/tests/Functional/BucketApiTest.php @@ -22,6 +22,7 @@ final class BucketApiTest extends WebTestCase private KernelBrowser $client; private EntityManagerInterface $em; + private User $user; protected function setUp(): void { @@ -36,11 +37,11 @@ final class BucketApiTest extends WebTestCase ]); $hasher = new UserPasswordHasher($factory); - $user = new User(); - $user->setEmail(self::EMAIL); - $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); + $this->user = new User(); + $this->user->setEmail(self::EMAIL); + $this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD)); - $this->em->persist($user); + $this->em->persist($this->user); $this->em->flush(); } @@ -62,6 +63,7 @@ final class BucketApiTest extends WebTestCase { $scenario = new Scenario(); $scenario->setName($name); + $scenario->setOwner($this->user); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Functional/ProjectionPreviewApiTest.php b/tests/Functional/ProjectionPreviewApiTest.php index 5447b33..110e399 100644 --- a/tests/Functional/ProjectionPreviewApiTest.php +++ b/tests/Functional/ProjectionPreviewApiTest.php @@ -27,6 +27,7 @@ final class ProjectionPreviewApiTest extends WebTestCase private KernelBrowser $client; private EntityManagerInterface $em; + private User $user; protected function setUp(): void { @@ -41,11 +42,11 @@ final class ProjectionPreviewApiTest extends WebTestCase ]); $hasher = new UserPasswordHasher($factory); - $user = new User(); - $user->setEmail(self::EMAIL); - $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); + $this->user = new User(); + $this->user->setEmail(self::EMAIL); + $this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD)); - $this->em->persist($user); + $this->em->persist($this->user); $this->em->flush(); } @@ -67,6 +68,7 @@ final class ProjectionPreviewApiTest extends WebTestCase { $scenario = new Scenario(); $scenario->setName($name); + $scenario->setOwner($this->user); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Functional/ScenarioApiTest.php b/tests/Functional/ScenarioApiTest.php index 56ff79a..d0f58d2 100644 --- a/tests/Functional/ScenarioApiTest.php +++ b/tests/Functional/ScenarioApiTest.php @@ -18,6 +18,7 @@ final class ScenarioApiTest extends WebTestCase private KernelBrowser $client; private EntityManagerInterface $em; + private User $user; protected function setUp(): void { @@ -32,11 +33,11 @@ final class ScenarioApiTest extends WebTestCase ]); $hasher = new UserPasswordHasher($factory); - $user = new User(); - $user->setEmail(self::EMAIL); - $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); + $this->user = new User(); + $this->user->setEmail(self::EMAIL); + $this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD)); - $this->em->persist($user); + $this->em->persist($this->user); $this->em->flush(); } @@ -58,6 +59,7 @@ final class ScenarioApiTest extends WebTestCase { $scenario = new Scenario(); $scenario->setName($name); + $scenario->setOwner($this->user); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Functional/ScenarioOwnerApiTest.php b/tests/Functional/ScenarioOwnerApiTest.php new file mode 100644 index 0000000..367fc12 --- /dev/null +++ b/tests/Functional/ScenarioOwnerApiTest.php @@ -0,0 +1,156 @@ +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.', + ); + } +} diff --git a/tests/Functional/StreamApiTest.php b/tests/Functional/StreamApiTest.php index f145fcd..f9e9f41 100644 --- a/tests/Functional/StreamApiTest.php +++ b/tests/Functional/StreamApiTest.php @@ -22,6 +22,7 @@ final class StreamApiTest extends WebTestCase private KernelBrowser $client; private EntityManagerInterface $em; + private User $user; protected function setUp(): void { @@ -36,11 +37,11 @@ final class StreamApiTest extends WebTestCase ]); $hasher = new UserPasswordHasher($factory); - $user = new User(); - $user->setEmail(self::EMAIL); - $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); + $this->user = new User(); + $this->user->setEmail(self::EMAIL); + $this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD)); - $this->em->persist($user); + $this->em->persist($this->user); $this->em->flush(); } @@ -62,6 +63,7 @@ final class StreamApiTest extends WebTestCase { $scenario = new Scenario(); $scenario->setName($name); + $scenario->setOwner($this->user); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Repository/BucketRepositoryTest.php b/tests/Repository/BucketRepositoryTest.php index ca9ba6f..6670cc6 100644 --- a/tests/Repository/BucketRepositoryTest.php +++ b/tests/Repository/BucketRepositoryTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Repository; use App\Entity\Bucket; use App\Entity\Scenario; +use App\Entity\User; use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Repository\BucketRepository; @@ -141,8 +142,17 @@ final class BucketRepositoryTest extends KernelTestCase 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->setName($name); + $scenario->setOwner($owner); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Validator/SingleOverflowPerScenarioTest.php b/tests/Validator/SingleOverflowPerScenarioTest.php index 1d07767..d9ac68f 100644 --- a/tests/Validator/SingleOverflowPerScenarioTest.php +++ b/tests/Validator/SingleOverflowPerScenarioTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Validator; use App\Entity\Bucket; use App\Entity\Scenario; +use App\Entity\User; use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Validator\SingleOverflowPerScenario; @@ -24,10 +25,25 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase $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 { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -54,6 +70,7 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); @@ -77,6 +94,7 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase { $scenario = new Scenario(); $scenario->setName('Household Budget'); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush();