buckets/tests/Functional/BucketOwnerApiTest.php

282 lines
10 KiB
PHP

<?php
namespace App\Tests\Functional;
use App\Entity\Bucket;
use App\Entity\Scenario;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
final class BucketOwnerApiTest extends WebTestCase
{
private const EMAIL = 'bucket-owner-api-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private const OTHER_EMAIL = 'bucket-owner-api-other@example.com';
private KernelBrowser $client;
private EntityManagerInterface $em;
private User $caller;
private User $otherUser;
protected function setUp(): void
{
$this->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.',
);
}
}