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