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.', ); } private function persistScenario(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; } 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(Scenario::class)->findOneBy(['name' => 'Holiday Fund']); self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.'); 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(Scenario::class)->findOneBy(['name' => 'Spoofed Owner Fund']); self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.'); 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 testGetItemOnAScenarioOwnedBySomeoneElseReturnsNotFound(): void { $othersScenario = new Scenario(); $othersScenario->setName('Someone Else\'s Fund'); $othersScenario->setOwner($this->otherUser); $this->em->persist($othersScenario); $this->em->flush(); $this->login(); $this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 404, $this->client->getResponse()->getStatusCode(), 'GET /api/scenarios/{id} for a scenario owned by another user must return 404, not 403 — no existence oracle for scenarios you do not own.', ); } public function testGetItemOnAScenarioOwnedBySomeoneElseReturnsNotFoundWithNoBucketLeak(): void { $othersScenario = $this->persistScenario($this->otherUser, 'Someone Else\'s Fund'); $this->persistBucket($othersScenario, 'Their Rent', 1); $this->persistBucket($othersScenario, 'Their Groceries', 2); $this->login(); $this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 404, $response->getStatusCode(), 'GET /api/scenarios/{id} for a scenario owned by another user must return 404, even when it has buckets.', ); $content = (string) $response->getContent(); self::assertStringNotContainsString( 'Their Rent', $content, 'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.', ); self::assertStringNotContainsString( 'Their Groceries', $content, 'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.', ); } public function testEmbeddedBucketsContainOnlyThisScenariosBuckets(): void { $scenarioA = $this->persistScenario($this->caller, 'Scenario A'); $this->persistBucket($scenarioA, 'A Rent', 1); $this->persistBucket($scenarioA, 'A Groceries', 2); $scenarioB = $this->persistScenario($this->caller, 'Scenario B'); $this->persistBucket($scenarioB, 'B Rent', 1); $this->persistBucket($scenarioB, 'B Groceries', 2); $this->login(); $this->client->request('GET', '/api/scenarios/'.$scenarioA->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame(200, $response->getStatusCode()); $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The item response must be a JSON-LD object.'); self::assertIsArray($body['buckets'] ?? null); $names = array_column($body['buckets'], 'name'); self::assertContains('A Rent', $names, 'Scenario A\'s item must embed its own buckets.'); self::assertContains('A Groceries', $names, 'Scenario A\'s item must embed its own buckets.'); self::assertNotContains( 'B Rent', $names, 'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both ' .'scenarios are owned by the same caller.', ); self::assertNotContains( 'B Groceries', $names, 'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both ' .'scenarios are owned by the same caller.', ); } public function testGetCollectionOnlyReturnsScenariosOwnedByTheCaller(): void { $mine = new Scenario(); $mine->setName('My Fund'); $mine->setOwner($this->caller); $this->em->persist($mine); $othersScenario = new Scenario(); $othersScenario->setName('Someone Else\'s Fund'); $othersScenario->setOwner($this->otherUser); $this->em->persist($othersScenario); $this->em->flush(); $this->login(); $this->client->request('GET', '/api/scenarios', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/scenarios 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 Fund', $names, 'GET /api/scenarios must include scenarios owned by the authenticated caller.', ); self::assertNotContains( 'Someone Else\'s Fund', $names, 'GET /api/scenarios must NOT include scenarios owned by other users.', ); } public function testAnonymousGetCollectionIsRejectedRatherThanReturningUnfilteredScenarios(): void { // The ownership extension no-ops for anonymous requests (no user to scope to), // so the resource-level `is_granted('ROLE_USER')` security MUST reject the // request before the query runs. If that wall ever relaxes, an anonymous caller // would receive an unfiltered listing — this test fails closed if that happens. $othersScenario = new Scenario(); $othersScenario->setName('Someone Else\'s Fund'); $othersScenario->setOwner($this->otherUser); $this->em->persist($othersScenario); $this->em->flush(); $this->client->request('GET', '/api/scenarios', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/scenarios without authentication must be rejected by the security layer, not served an unfiltered collection.', ); } public function testAnonymousGetItemIsRejectedRatherThanResolvingScenario(): void { $othersScenario = new Scenario(); $othersScenario->setName('Someone Else\'s Fund'); $othersScenario->setOwner($this->otherUser); $this->em->persist($othersScenario); $this->em->flush(); $this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/scenarios/{id} without authentication must be rejected by the security layer.', ); } 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.', ); } }