diff --git a/src/Doctrine/AbstractOwnerScopeExtension.php b/src/Doctrine/AbstractOwnerScopeExtension.php new file mode 100644 index 0000000..954c559 --- /dev/null +++ b/src/Doctrine/AbstractOwnerScopeExtension.php @@ -0,0 +1,94 @@ +.owner = :currentUser`. Non-owned rows become unresolvable, + * so API Platform's provider 404s naturally on item reads and silently filters + * them out of collections — no 403, no existence oracle. + * + * Subclasses declare which resource they guard (getResourceClass) and which alias + * carries the `owner` column (ownerAlias) — resolving it directly for a resource + * that owns the column, or JOINing toward the owning resource and returning that + * join's alias for transitively-owned resources. + */ +abstract class AbstractOwnerScopeExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +{ + public function __construct( + private readonly Security $security, + ) { + } + + public function applyToCollection( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ?Operation $operation = null, + array $context = [], + ): void { + $this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass); + } + + public function applyToItem( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + array $identifiers, + ?Operation $operation = null, + array $context = [], + ): void { + $this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass); + } + + /** + * The resource class this extension scopes. The extension fires for every + * resource's queries, so it must no-op for anything but its own. + * + * @return class-string + */ + abstract protected function getResourceClass(): string; + + /** + * Returns the query alias whose `owner` column identifies the resource's + * owner. Implementations that own the column return the root alias; those + * owned transitively JOIN toward the owning resource (mutating the builder) + * and return that join's alias. + */ + abstract protected function ownerAlias( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $rootAlias, + ): string; + + private function addOwnerWhere( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ): void { + if ($this->getResourceClass() !== $resourceClass) { + return; + } + + $user = $this->security->getUser(); + if (!$user instanceof User) { + return; + } + + $rootAlias = $queryBuilder->getRootAliases()[0]; + $ownerAlias = $this->ownerAlias($queryBuilder, $queryNameGenerator, $rootAlias); + $param = $queryNameGenerator->generateParameterName('currentUser'); + + $queryBuilder + ->andWhere(\sprintf('%s.owner = :%s', $ownerAlias, $param)) + ->setParameter($param, $user); + } +} diff --git a/src/Doctrine/BucketOwnerExtension.php b/src/Doctrine/BucketOwnerExtension.php index 23cbc8b..bf9f30d 100644 --- a/src/Doctrine/BucketOwnerExtension.php +++ b/src/Doctrine/BucketOwnerExtension.php @@ -2,73 +2,32 @@ namespace App\Doctrine; -use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; -use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Metadata\Operation; use App\Entity\Bucket; -use App\Entity\User; use Doctrine\ORM\QueryBuilder; -use Symfony\Bundle\SecurityBundle\Security; /** * Scopes every Bucket read to the authenticated owner. A Bucket has no direct * owner column — ownership is transitive via its Scenario — so this JOINs the - * `scenario` relation and appends `WHERE scenario.owner = :currentUser`. - * Non-owned rows become unresolvable, so API Platform's provider 404s naturally - * on item reads and silently filters them out of collections — no 403, no - * existence oracle. + * `scenario` relation and filters on that join's `owner`. + * + * @see AbstractOwnerScopeExtension for the 404-not-403 mechanism. */ -final class BucketOwnerExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +final class BucketOwnerExtension extends AbstractOwnerScopeExtension { - public function __construct( - private readonly Security $security, - ) { + protected function getResourceClass(): string + { + return Bucket::class; } - public function applyToCollection( + protected function ownerAlias( QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - ?Operation $operation = null, - array $context = [], - ): void { - $this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass); - } - - public function applyToItem( - QueryBuilder $queryBuilder, - QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - array $identifiers, - ?Operation $operation = null, - array $context = [], - ): void { - $this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass); - } - - private function addOwnerWhere( - QueryBuilder $queryBuilder, - QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - ): void { - // This extension fires for every resource's queries — only act on Bucket. - if (Bucket::class !== $resourceClass) { - return; - } - - $user = $this->security->getUser(); - if (!$user instanceof User) { - return; - } - - $rootAlias = $queryBuilder->getRootAliases()[0]; + string $rootAlias, + ): string { $scenarioAlias = $queryNameGenerator->generateJoinAlias('scenario'); - $param = $queryNameGenerator->generateParameterName('currentUser'); + $queryBuilder->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias); - $queryBuilder - ->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias) - ->andWhere(\sprintf('%s.owner = :%s', $scenarioAlias, $param)) - ->setParameter($param, $user); + return $scenarioAlias; } } diff --git a/src/Doctrine/ScenarioOwnerExtension.php b/src/Doctrine/ScenarioOwnerExtension.php index 62b9bf6..69ff909 100644 --- a/src/Doctrine/ScenarioOwnerExtension.php +++ b/src/Doctrine/ScenarioOwnerExtension.php @@ -2,68 +2,28 @@ namespace App\Doctrine; -use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; -use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Metadata\Operation; use App\Entity\Scenario; -use App\Entity\User; use Doctrine\ORM\QueryBuilder; -use Symfony\Bundle\SecurityBundle\Security; /** - * Scopes every Scenario read to the authenticated owner by appending - * `WHERE owner = :currentUser`. Non-owned rows become unresolvable, so - * API Platform's provider 404s naturally — no 403, no existence oracle. + * Scopes every Scenario read to the authenticated owner. Scenario owns the + * `owner` column directly, so the filter applies straight to the root alias. + * + * @see AbstractOwnerScopeExtension for the 404-not-403 mechanism. */ -final class ScenarioOwnerExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +final class ScenarioOwnerExtension extends AbstractOwnerScopeExtension { - public function __construct( - private readonly Security $security, - ) { + protected function getResourceClass(): string + { + return Scenario::class; } - public function applyToCollection( + protected function ownerAlias( QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - ?Operation $operation = null, - array $context = [], - ): void { - $this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass); - } - - public function applyToItem( - QueryBuilder $queryBuilder, - QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - array $identifiers, - ?Operation $operation = null, - array $context = [], - ): void { - $this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass); - } - - private function addOwnerWhere( - QueryBuilder $queryBuilder, - QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - ): void { - // This extension fires for every resource's queries — only act on Scenario. - if (Scenario::class !== $resourceClass) { - return; - } - - $user = $this->security->getUser(); - if (!$user instanceof User) { - return; - } - - $rootAlias = $queryBuilder->getRootAliases()[0]; - $param = $queryNameGenerator->generateParameterName('currentUser'); - - $queryBuilder - ->andWhere(\sprintf('%s.owner = :%s', $rootAlias, $param)) - ->setParameter($param, $user); + string $rootAlias, + ): string { + return $rootAlias; } } diff --git a/src/Doctrine/StreamOwnerExtension.php b/src/Doctrine/StreamOwnerExtension.php new file mode 100644 index 0000000..cd347d4 --- /dev/null +++ b/src/Doctrine/StreamOwnerExtension.php @@ -0,0 +1,39 @@ +generateJoinAlias('scenario'); + $queryBuilder->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias); + + return $scenarioAlias; + } +} diff --git a/tests/Concerns/CreatesUsers.php b/tests/Concerns/CreatesUsers.php new file mode 100644 index 0000000..42beebc --- /dev/null +++ b/tests/Concerns/CreatesUsers.php @@ -0,0 +1,30 @@ +em. + */ +trait CreatesUsers +{ + private function persistOwner(): User + { + static $counter = 0; + ++$counter; + + $prefix = strtolower(str_replace('\\', '-', static::class)); + + $owner = new User(); + $owner->setEmail(\sprintf('%s-%d@example.com', $prefix, $counter)); + $owner->setPassword('irrelevant-hash'); + + $this->em->persist($owner); + + return $owner; + } +} diff --git a/tests/Entity/BucketPersistenceTest.php b/tests/Entity/BucketPersistenceTest.php index ac7d0a8..529b567 100644 --- a/tests/Entity/BucketPersistenceTest.php +++ b/tests/Entity/BucketPersistenceTest.php @@ -4,9 +4,9 @@ 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 App\Tests\Concerns\CreatesUsers; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -14,6 +14,8 @@ use Symfony\Component\Uid\UuidV7; final class BucketPersistenceTest extends KernelTestCase { + use CreatesUsers; + private EntityManagerInterface $em; protected function setUp(): void @@ -22,20 +24,6 @@ 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(); diff --git a/tests/Entity/ScenarioPersistenceTest.php b/tests/Entity/ScenarioPersistenceTest.php index 3fee263..bf8b332 100644 --- a/tests/Entity/ScenarioPersistenceTest.php +++ b/tests/Entity/ScenarioPersistenceTest.php @@ -3,14 +3,16 @@ namespace App\Tests\Entity; use App\Entity\Scenario; -use App\Entity\User; use App\Enum\DistributionMode; +use App\Tests\Concerns\CreatesUsers; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Uid\UuidV7; final class ScenarioPersistenceTest extends KernelTestCase { + use CreatesUsers; + private EntityManagerInterface $em; protected function setUp(): void @@ -19,20 +21,6 @@ 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(); diff --git a/tests/Entity/StreamPersistenceTest.php b/tests/Entity/StreamPersistenceTest.php index 9a4c2ae..327a3bc 100644 --- a/tests/Entity/StreamPersistenceTest.php +++ b/tests/Entity/StreamPersistenceTest.php @@ -5,16 +5,18 @@ 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; use App\Enum\StreamType; +use App\Tests\Concerns\CreatesUsers; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; final class StreamPersistenceTest extends KernelTestCase { + use CreatesUsers; + private EntityManagerInterface $em; protected function setUp(): void @@ -23,20 +25,6 @@ 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(); diff --git a/tests/Functional/StreamOwnerApiTest.php b/tests/Functional/StreamOwnerApiTest.php new file mode 100644 index 0000000..ed445a4 --- /dev/null +++ b/tests/Functional/StreamOwnerApiTest.php @@ -0,0 +1,412 @@ +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 Stream API.', + ); + } + + private function persistScenarioFor(User $owner, string $name): Scenario + { + $scenario = new Scenario(); + $scenario->setName($name); + $scenario->setOwner($owner); + + $this->em->persist($scenario); + $this->em->flush(); + + return $scenario; + } + + private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket + { + $bucket = new Bucket(); + $bucket->setScenario($scenario); + $bucket->setName($name); + $bucket->setPriority($priority); + + $this->em->persist($bucket); + $this->em->flush(); + + return $bucket; + } + + private function persistStream(Scenario $scenario, string $name, ?Bucket $bucket = null): Stream + { + $stream = new Stream(); + $stream->setScenario($scenario); + $stream->setBucket($bucket); + $stream->setName($name); + $stream->setAmount(1000); + $stream->setType(StreamType::INCOME); + $stream->setFrequency(StreamFrequency::MONTHLY); + $stream->setStartDate(new \DateTimeImmutable('2026-01-01')); + + $this->em->persist($stream); + $this->em->flush(); + + return $stream; + } + + /** + * Minimal valid Stream POST payload, scoped under the given scenario IRI + * (and optionally a bucket IRI). Mirrors the entity's required fields: + * name, amount, type, frequency, startDate, scenario. + * + * @return array + */ + private function streamPayload(string $scenarioIri, ?string $bucketIri = null, string $name = 'Paycheck'): array + { + $payload = [ + 'scenario' => $scenarioIri, + 'name' => $name, + 'amount' => 1000, + 'type' => StreamType::INCOME->value, + 'frequency' => StreamFrequency::MONTHLY->value, + 'startDate' => '2026-01-01', + ]; + + if (null !== $bucketIri) { + $payload['bucket'] = $bucketIri; + } + + return $payload; + } + + public function testGetItemOnAStreamOwnedBySomeoneElseReturnsNotFound(): void + { + $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); + $othersStream = $this->persistStream($othersScenario, 'Their Paycheck'); + + $this->login(); + + $this->client->request('GET', '/api/streams/'.$othersStream->getId(), server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + self::assertSame( + 404, + $this->client->getResponse()->getStatusCode(), + 'GET /api/streams/{id} for a stream whose scenario is owned by another user must return 404, not 403 — ' + .'no existence oracle for streams you do not own, scoped transitively via scenario.owner.', + ); + } + + public function testGetCollectionOnlyReturnsStreamsWhoseScenarioIsOwnedByTheCaller(): void + { + $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); + $this->persistStream($myScenario, 'My Paycheck'); + + $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); + $this->persistStream($othersScenario, 'Their Paycheck'); + + $this->login(); + + $this->client->request('GET', '/api/streams', server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + $response = $this->client->getResponse(); + + self::assertSame( + 200, + $response->getStatusCode(), + 'GET /api/streams must return 200 for an authenticated user.', + ); + + $body = json_decode((string) $response->getContent(), true); + + self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.'); + self::assertArrayHasKey( + 'member', + $body, + 'A Hydra collection response must expose its items under the member key.', + ); + + $names = array_column($body['member'], 'name'); + + self::assertContains( + 'My Paycheck', + $names, + 'GET /api/streams must include streams whose scenario is owned by the authenticated caller.', + ); + self::assertNotContains( + 'Their Paycheck', + $names, + 'GET /api/streams must NOT include streams whose scenario is owned by another user, ' + .'even though the stream itself has no direct owner column — ownership is transitive via scenario.owner.', + ); + } + + public function testAnonymousGetCollectionIsRejectedRatherThanReturningUnfilteredStreams(): void + { + $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); + $this->persistStream($othersScenario, 'Their Paycheck'); + + $this->client->request('GET', '/api/streams', server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'GET /api/streams without authentication must be rejected by the security layer, not served an ' + .'unfiltered collection — the ownership extension no-ops for anonymous requests, so the resource-level ' + .'security wall must reject first.', + ); + } + + public function testAnonymousGetItemIsRejectedRatherThanResolvingStream(): void + { + $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); + $othersStream = $this->persistStream($othersScenario, 'Their Paycheck'); + + $this->client->request('GET', '/api/streams/'.$othersStream->getId(), server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'GET /api/streams/{id} without authentication must be rejected by the security layer.', + ); + } + + /** + * PINS OBSERVED (PRE-#50-STORY-5) BEHAVIOUR — flagged for review, not a locked contract. + * + * Mirrors BucketOwnerApiTest::testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound. + * A foreign scenario IRI is already unresolvable to a non-owner (Scenario's own + * ownership extension, #50 Story 2), so API Platform's IRI denormalizer raises an + * "Item not found" failure during deserialization, surfaced as 400 — BEFORE any + * Stream-specific extension code runs. This test asserts the OBSERVED 400, not a + * desired 404. If a 404 contract is required, it needs an explicit normalizing + * guard — an implementation decision, not assumed here. + */ + public function testPostUnderAForeignScenarioCurrentlyReturnsBadRequest(): void + { + $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode($this->streamPayload( + '/api/scenarios/'.$othersScenario->getId(), + name: 'Sneaky Stream', + )), + ); + + self::assertSame( + 400, + $this->client->getResponse()->getStatusCode(), + 'POST /api/streams under a scenario IRI the caller does not own currently resolves to 400 ' + .'(unresolvable IRI denormalization failure), not 201 — pinning the observed behaviour. ' + .'See class docblock above this test: a 404 may require an explicit normalizing guard.', + ); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Sneaky Stream']); + + self::assertNull( + $persisted, + 'A rejected POST under a foreign scenario must never persist a Stream.', + ); + } + + /** + * THE WRINKLE — Story 5's distinguishing case. Unlike Bucket (which only points at + * a scenario), Stream POST can carry a SEPARATE bucket IRI. Even when the scenario + * IRI belongs to the caller, a foreign bucket IRI must not be acceptable — otherwise + * a non-owner could attach a stream to a bucket they don't own (cross-tenant write + * via the optional bucket leg). This test pins WHATEVER status the app currently + * returns; if it is 201, that is a real ownership gap — the bucket relation resolves + * despite the caller not owning it, and the create must be rejected before persisting. + */ + public function testPostUnderOwnScenarioButForeignBucketIsRejected(): void + { + $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); + + $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); + $othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode($this->streamPayload( + '/api/scenarios/'.$myScenario->getId(), + bucketIri: '/api/buckets/'.$othersBucket->getId(), + name: 'Cross Tenant Stream', + )), + ); + + self::assertSame( + 400, + $this->client->getResponse()->getStatusCode(), + 'POST /api/streams under the caller\'s OWN scenario but referencing a bucket IRI owned by ANOTHER ' + .'user must be rejected, not created — a non-owner must not be able to attach a stream to someone ' + .'else\'s bucket. Pinning the OBSERVED 400 (not Stream-owned logic — an incidental side-effect of ' + .'BucketOwnerExtension making the foreign bucket IRI unresolvable during denormalization, same ' + .'mechanism as the foreign-scenario case above). If this ever regresses to 403, that is an ' + .'existence-oracle leak (confirms the bucket exists but isn\'t yours) — exactly what #50 exists to ' + .'prevent. If it regresses to 201, that is a real ownership gap: the bucket relation resolved ' + .'despite the caller not owning it.', + ); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Cross Tenant Stream']); + + self::assertNull( + $persisted, + 'A rejected POST referencing a foreign bucket must never persist a Stream.', + ); + } + + public function testPostUnderTheCallersOwnScenarioAndOwnBucketSucceeds(): void + { + $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); + $myBucket = $this->persistBucket($myScenario, 'My Rent', 1); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode($this->streamPayload( + '/api/scenarios/'.$myScenario->getId(), + bucketIri: '/api/buckets/'.$myBucket->getId(), + name: 'Paycheck', + )), + ); + + self::assertSame( + 201, + $this->client->getResponse()->getStatusCode(), + 'POST /api/streams under the caller\'s own scenario AND own bucket must succeed — the ownership ' + .'scoping must not regress the happy path.', + ); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Paycheck']); + + self::assertNotNull( + $persisted, + 'A successful POST /api/streams under the caller\'s own scenario and bucket must persist the new Stream.', + ); + } + + public function testPostUnderTheCallersOwnScenarioWithNoBucketSucceeds(): void + { + $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode($this->streamPayload( + '/api/scenarios/'.$myScenario->getId(), + name: 'Unassigned Income', + )), + ); + + self::assertSame( + 201, + $this->client->getResponse()->getStatusCode(), + 'POST /api/streams under the caller\'s own scenario with NO bucket (null is valid — bucket is ' + .'optional) must succeed.', + ); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Unassigned Income']); + + self::assertNotNull( + $persisted, + 'A successful POST /api/streams with a null bucket must persist the new Stream.', + ); + } +} diff --git a/tests/Repository/BucketRepositoryTest.php b/tests/Repository/BucketRepositoryTest.php index 6670cc6..c5a97e5 100644 --- a/tests/Repository/BucketRepositoryTest.php +++ b/tests/Repository/BucketRepositoryTest.php @@ -4,15 +4,17 @@ 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; +use App\Tests\Concerns\CreatesUsers; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; final class BucketRepositoryTest extends KernelTestCase { + use CreatesUsers; + private EntityManagerInterface $em; private BucketRepository $repository; @@ -142,17 +144,9 @@ 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); + $scenario->setOwner($this->persistOwner()); $this->em->persist($scenario); $this->em->flush(); diff --git a/tests/Validator/SingleOverflowPerScenarioTest.php b/tests/Validator/SingleOverflowPerScenarioTest.php index d9ac68f..c451c99 100644 --- a/tests/Validator/SingleOverflowPerScenarioTest.php +++ b/tests/Validator/SingleOverflowPerScenarioTest.php @@ -4,9 +4,9 @@ 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\Tests\Concerns\CreatesUsers; use App\Validator\SingleOverflowPerScenario; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -15,6 +15,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; final class SingleOverflowPerScenarioTest extends KernelTestCase { + use CreatesUsers; + private EntityManagerInterface $em; private ValidatorInterface $validator; @@ -25,20 +27,6 @@ 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();