399 lines
15 KiB
PHP
399 lines
15 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 the LOCKED 400-not-404 decision for a bucket POSTed under a scenario IRI
|
|
* the caller does not own (ratified project-wide — see PLATFORM.md "Per-user ownership").
|
|
*
|
|
* 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 happens BEFORE any Bucket-specific
|
|
* extension code runs. Forcing 404 would require a normalizing guard that risks
|
|
* masking legitimate 400s; the security properties are already met (no write, no
|
|
* leak, no oracle), so 400 stays.
|
|
*/
|
|
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 {@see testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound()}:
|
|
* the LOCKED 400-not-404 decision applies to an attempted cross-tenant re-parent via PATCH too.
|
|
*
|
|
* 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. 400 is the ratified outcome (no write, no leak, no oracle); 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.',
|
|
);
|
|
}
|
|
}
|