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.', ); } }