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 Bucket 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; } public function testGetItemOnABucketOwnedBySomeoneElseReturnsNotFound(): void { $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1); $this->login(); $this->client->request('GET', '/api/buckets/'.$othersBucket->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 404, $this->client->getResponse()->getStatusCode(), 'GET /api/buckets/{id} for a bucket whose scenario is owned by another user must return 404, not 403 — ' .'no existence oracle for buckets you do not own, scoped transitively via scenario.owner.', ); } public function testGetCollectionOnlyReturnsBucketsWhoseScenarioIsOwnedByTheCaller(): void { $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); $this->persistBucket($myScenario, 'My Rent', 1); $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $this->persistBucket($othersScenario, 'Their Rent', 1); $this->login(); $this->client->request('GET', '/api/buckets', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/buckets 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 Rent', $names, 'GET /api/buckets must include buckets whose scenario is owned by the authenticated caller.', ); self::assertNotContains( 'Their Rent', $names, 'GET /api/buckets must NOT include buckets whose scenario is owned by another user, ' .'even though the bucket itself has no direct owner column — ownership is transitive via scenario.owner.', ); } /** * PINS CURRENT (PRE-#50-STORY-3) BEHAVIOUR — flagged for review, not a locked contract. * * The ticket asks for 404 when POSTing a bucket under a scenario IRI the caller * does not own. Empirically, today this is 400: API Platform's IRI denormalizer * (`AbstractItemNormalizer::getResourceFromIri`) raises "Item not found" as soon as * the `scenario` relation can't be resolved — Scenario's own ownership extension * (#50 Story 2) already makes a foreign scenario IRI unresolvable to a non-owner, * so this 400 already happens BEFORE any Bucket-specific extension code runs. * * This test asserts the OBSERVED 400, not the ticket's desired 404. If a 404 is * required, it needs a normalizing guard (e.g. catching the IRI-resolution failure * and re-throwing as NotFoundHttpException) — that's an implementation decision, * flagged to the user rather than assumed. */ public function testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound(): void { $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => '/api/scenarios/'.$othersScenario->getId(), 'name' => 'Sneaky Bucket', 'priority' => 1, ]), ); $response = $this->client->getResponse(); self::assertSame( 400, $response->getStatusCode(), 'POST /api/buckets 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(Bucket::class)->findOneBy(['name' => 'Sneaky Bucket']); self::assertNull( $persisted, 'A rejected POST under a foreign scenario must never persist a Bucket.', ); } public function testPostUnderTheCallersOwnScenarioStillSucceeds(): void { $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => '/api/scenarios/'.$myScenario->getId(), 'name' => 'Groceries', 'priority' => 1, ]), ); self::assertSame( 201, $this->client->getResponse()->getStatusCode(), 'POST /api/buckets under a scenario the caller owns must still succeed — the ownership scoping ' .'must not regress the happy path.', ); $this->em->clear(); $persisted = $this->em->getRepository(Bucket::class)->findOneBy(['name' => 'Groceries']); self::assertNotNull( $persisted, 'A successful POST /api/buckets under the caller\'s own scenario must persist the new Bucket.', ); } public function testAnonymousGetCollectionIsRejectedRatherThanReturningUnfilteredBuckets(): void { $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $this->persistBucket($othersScenario, 'Their Rent', 1); $this->client->request('GET', '/api/buckets', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/buckets 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 testAnonymousGetItemIsRejectedRatherThanResolvingBucket(): void { $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1); $this->client->request('GET', '/api/buckets/'.$othersBucket->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/buckets/{id} without authentication must be rejected by the security layer.', ); } public function testPatchOnABucketOwnedBySomeoneElseReturnsNotFound(): void { $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1); $this->login(); $this->client->request( 'PATCH', '/api/buckets/'.$othersBucket->getId(), server: [ 'CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['name' => 'Sneaky Rename']), ); self::assertSame( 404, $this->client->getResponse()->getStatusCode(), 'PATCH /api/buckets/{id} for a bucket whose scenario is owned by another user must return 404, not 403 — ' .'no existence oracle for buckets you do not own, scoped transitively via scenario.owner.', ); $this->em->clear(); $persisted = $this->em->getRepository(Bucket::class)->find($othersBucket->getId()); self::assertNotNull($persisted, 'The other user\'s bucket must still exist.'); self::assertSame( 'Their Rent', $persisted->getName(), 'A rejected PATCH against a non-owned bucket must never mutate it — no mutation must leak through ' .'the 404 item-resolution failure.', ); } public function testDeleteOnABucketOwnedBySomeoneElseReturnsNotFound(): void { $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1); $this->login(); $this->client->request( 'DELETE', '/api/buckets/'.$othersBucket->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ], ); self::assertSame( 404, $this->client->getResponse()->getStatusCode(), 'DELETE /api/buckets/{id} for a bucket whose scenario is owned by another user must return 404, not ' .'403 — no existence oracle for buckets you do not own, scoped transitively via scenario.owner.', ); $this->em->clear(); $persisted = $this->em->getRepository(Bucket::class)->find($othersBucket->getId()); self::assertNotNull( $persisted, 'A rejected DELETE against a non-owned bucket must never delete it — no cross-tenant deletion must ' .'leak through the 404 item-resolution failure.', ); } /** * Mirrors the class-level docblock on {@see testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound()}: * this pins OBSERVED behaviour for an attempted cross-tenant re-parent via PATCH, not an assumed contract. * * The caller PATCHes their OWN bucket but supplies a `scenario` IRI pointing at a scenario owned by * `otherUser`. `ScenarioOwnerExtension` (#50 Story 2) already makes that IRI unresolvable to a non-owner, * so API Platform's IRI denormalizer fails to resolve `scenario` while building the merge-patched object — * the same `AbstractItemNormalizer::getResourceFromIri` path that produces 400 on the analogous foreign-scenario * POST. Empirically this also resolves to 400 here. Pinning it as a coherent rejection: the bucket is * confirmed NOT re-parented below. */ public function testPatchReparentingIntoForeignScenarioIsRejected(): void { $myScenario = $this->persistScenarioFor($this->caller, 'My Fund'); $myBucket = $this->persistBucket($myScenario, 'Groceries', 1); $othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund'); $this->login(); $this->client->request( 'PATCH', '/api/buckets/'.$myBucket->getId(), server: [ 'CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['scenario' => '/api/scenarios/'.$othersScenario->getId()]), ); self::assertSame( 400, $this->client->getResponse()->getStatusCode(), 'PATCH /api/buckets/{id} attempting to re-parent the caller\'s own bucket into a scenario owned by ' .'another user currently resolves to 400 (unresolvable IRI denormalization failure), mirroring the ' .'foreign-scenario POST behaviour — pinning the observed rejection. See docblock above this test.', ); $this->em->clear(); $persisted = $this->em->getRepository(Bucket::class)->find($myBucket->getId()); self::assertNotNull($persisted, 'The caller\'s bucket must still exist.'); self::assertSame( $myScenario->getId()->toRfc4122(), $persisted->getScenario()->getId()->toRfc4122(), 'A rejected re-parenting PATCH must never actually move the bucket into the foreign scenario — ' .'the bucket must remain under its original scenario.', ); } }