50 - Scope streams to the owner transitively via scenario
This commit is contained in:
parent
6dd8e2207f
commit
473b88b54e
11 changed files with 615 additions and 175 deletions
94
src/Doctrine/AbstractOwnerScopeExtension.php
Normal file
94
src/Doctrine/AbstractOwnerScopeExtension.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Scopes every read of an owned resource to the authenticated owner by appending
|
||||
* `WHERE <ownerAlias>.owner = :currentUser`. Non-owned rows become unresolvable,
|
||||
* so API Platform's provider 404s naturally on item reads and silently filters
|
||||
* them out of collections — no 403, no existence oracle.
|
||||
*
|
||||
* Subclasses declare which resource they guard (getResourceClass) and which alias
|
||||
* carries the `owner` column (ownerAlias) — resolving it directly for a resource
|
||||
* that owns the column, or JOINing toward the owning resource and returning that
|
||||
* join's alias for transitively-owned resources.
|
||||
*/
|
||||
abstract class AbstractOwnerScopeExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* The resource class this extension scopes. The extension fires for every
|
||||
* resource's queries, so it must no-op for anything but its own.
|
||||
*
|
||||
* @return class-string
|
||||
*/
|
||||
abstract protected function getResourceClass(): string;
|
||||
|
||||
/**
|
||||
* Returns the query alias whose `owner` column identifies the resource's
|
||||
* owner. Implementations that own the column return the root alias; those
|
||||
* owned transitively JOIN toward the owning resource (mutating the builder)
|
||||
* and return that join's alias.
|
||||
*/
|
||||
abstract protected function ownerAlias(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $rootAlias,
|
||||
): string;
|
||||
|
||||
private function addOwnerWhere(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
if ($this->getResourceClass() !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$ownerAlias = $this->ownerAlias($queryBuilder, $queryNameGenerator, $rootAlias);
|
||||
$param = $queryNameGenerator->generateParameterName('currentUser');
|
||||
|
||||
$queryBuilder
|
||||
->andWhere(\sprintf('%s.owner = :%s', $ownerAlias, $param))
|
||||
->setParameter($param, $user);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,73 +2,32 @@
|
|||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Scopes every Bucket read to the authenticated owner. A Bucket has no direct
|
||||
* owner column — ownership is transitive via its Scenario — so this JOINs the
|
||||
* `scenario` relation and appends `WHERE scenario.owner = :currentUser`.
|
||||
* Non-owned rows become unresolvable, so API Platform's provider 404s naturally
|
||||
* on item reads and silently filters them out of collections — no 403, no
|
||||
* existence oracle.
|
||||
* `scenario` relation and filters on that join's `owner`.
|
||||
*
|
||||
* @see AbstractOwnerScopeExtension for the 404-not-403 mechanism.
|
||||
*/
|
||||
final class BucketOwnerExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
final class BucketOwnerExtension extends AbstractOwnerScopeExtension
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {
|
||||
protected function getResourceClass(): string
|
||||
{
|
||||
return Bucket::class;
|
||||
}
|
||||
|
||||
public function applyToCollection(
|
||||
protected function ownerAlias(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
private function addOwnerWhere(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// This extension fires for every resource's queries — only act on Bucket.
|
||||
if (Bucket::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
string $rootAlias,
|
||||
): string {
|
||||
$scenarioAlias = $queryNameGenerator->generateJoinAlias('scenario');
|
||||
$param = $queryNameGenerator->generateParameterName('currentUser');
|
||||
$queryBuilder->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias);
|
||||
|
||||
$queryBuilder
|
||||
->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias)
|
||||
->andWhere(\sprintf('%s.owner = :%s', $scenarioAlias, $param))
|
||||
->setParameter($param, $user);
|
||||
return $scenarioAlias;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,68 +2,28 @@
|
|||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Scopes every Scenario read to the authenticated owner by appending
|
||||
* `WHERE owner = :currentUser`. Non-owned rows become unresolvable, so
|
||||
* API Platform's provider 404s naturally — no 403, no existence oracle.
|
||||
* Scopes every Scenario read to the authenticated owner. Scenario owns the
|
||||
* `owner` column directly, so the filter applies straight to the root alias.
|
||||
*
|
||||
* @see AbstractOwnerScopeExtension for the 404-not-403 mechanism.
|
||||
*/
|
||||
final class ScenarioOwnerExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
final class ScenarioOwnerExtension extends AbstractOwnerScopeExtension
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {
|
||||
protected function getResourceClass(): string
|
||||
{
|
||||
return Scenario::class;
|
||||
}
|
||||
|
||||
public function applyToCollection(
|
||||
protected function ownerAlias(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
private function addOwnerWhere(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// This extension fires for every resource's queries — only act on Scenario.
|
||||
if (Scenario::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$param = $queryNameGenerator->generateParameterName('currentUser');
|
||||
|
||||
$queryBuilder
|
||||
->andWhere(\sprintf('%s.owner = :%s', $rootAlias, $param))
|
||||
->setParameter($param, $user);
|
||||
string $rootAlias,
|
||||
): string {
|
||||
return $rootAlias;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/Doctrine/StreamOwnerExtension.php
Normal file
39
src/Doctrine/StreamOwnerExtension.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use App\Entity\Stream;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Scopes every Stream read to the authenticated owner. A Stream has no direct
|
||||
* owner column — ownership is transitive via its Scenario — so this JOINs the
|
||||
* `scenario` relation and filters on that join's `owner`.
|
||||
*
|
||||
* The optional `bucket` relation is deliberately NOT considered here: a Stream's
|
||||
* ownership is determined solely by its Scenario. Cross-tenant writes that name a
|
||||
* foreign bucket IRI are already blocked on the write path (the BucketOwner
|
||||
* extension makes that IRI unresolvable during denormalization), so reads need
|
||||
* only scope on `scenario.owner`.
|
||||
*
|
||||
* @see AbstractOwnerScopeExtension for the 404-not-403 mechanism.
|
||||
*/
|
||||
final class StreamOwnerExtension extends AbstractOwnerScopeExtension
|
||||
{
|
||||
protected function getResourceClass(): string
|
||||
{
|
||||
return Stream::class;
|
||||
}
|
||||
|
||||
protected function ownerAlias(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $rootAlias,
|
||||
): string {
|
||||
$scenarioAlias = $queryNameGenerator->generateJoinAlias('scenario');
|
||||
$queryBuilder->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias);
|
||||
|
||||
return $scenarioAlias;
|
||||
}
|
||||
}
|
||||
30
tests/Concerns/CreatesUsers.php
Normal file
30
tests/Concerns/CreatesUsers.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Concerns;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* Persists throwaway User owners for tests that need a Scenario owner but don't
|
||||
* care about the user itself. Emails are made unique per test class + call so
|
||||
* the same suite can mint many owners without colliding on the unique email
|
||||
* constraint. The consuming test must expose the EntityManager as $this->em.
|
||||
*/
|
||||
trait CreatesUsers
|
||||
{
|
||||
private function persistOwner(): User
|
||||
{
|
||||
static $counter = 0;
|
||||
++$counter;
|
||||
|
||||
$prefix = strtolower(str_replace('\\', '-', static::class));
|
||||
|
||||
$owner = new User();
|
||||
$owner->setEmail(\sprintf('%s-%d@example.com', $prefix, $counter));
|
||||
$owner->setPassword('irrelevant-hash');
|
||||
|
||||
$this->em->persist($owner);
|
||||
|
||||
return $owner;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ namespace App\Tests\Entity;
|
|||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Tests\Concerns\CreatesUsers;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
|
@ -14,6 +14,8 @@ use Symfony\Component\Uid\UuidV7;
|
|||
|
||||
final class BucketPersistenceTest extends KernelTestCase
|
||||
{
|
||||
use CreatesUsers;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -22,20 +24,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
|||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function persistOwner(): User
|
||||
{
|
||||
static $counter = 0;
|
||||
++$counter;
|
||||
|
||||
$owner = new User();
|
||||
$owner->setEmail(\sprintf('bucket-persistence-test-%d@example.com', $counter));
|
||||
$owner->setPassword('irrelevant-hash');
|
||||
|
||||
$this->em->persist($owner);
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
public function testItPersistsABucketLinkedToItsScenario(): void
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@
|
|||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use App\Enum\DistributionMode;
|
||||
use App\Tests\Concerns\CreatesUsers;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Uid\UuidV7;
|
||||
|
||||
final class ScenarioPersistenceTest extends KernelTestCase
|
||||
{
|
||||
use CreatesUsers;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -19,20 +21,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
|||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function persistOwner(): User
|
||||
{
|
||||
static $counter = 0;
|
||||
++$counter;
|
||||
|
||||
$owner = new User();
|
||||
$owner->setEmail(\sprintf('scenario-persistence-test-%d@example.com', $counter));
|
||||
$owner->setPassword('irrelevant-hash');
|
||||
|
||||
$this->em->persist($owner);
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
public function testItPersistsAScenarioWithAUuidId(): void
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
|
|
|
|||
|
|
@ -5,16 +5,18 @@ namespace App\Tests\Entity;
|
|||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\Stream;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Enum\StreamFrequency;
|
||||
use App\Enum\StreamType;
|
||||
use App\Tests\Concerns\CreatesUsers;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
final class StreamPersistenceTest extends KernelTestCase
|
||||
{
|
||||
use CreatesUsers;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
@ -23,20 +25,6 @@ final class StreamPersistenceTest extends KernelTestCase
|
|||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function persistOwner(): User
|
||||
{
|
||||
static $counter = 0;
|
||||
++$counter;
|
||||
|
||||
$owner = new User();
|
||||
$owner->setEmail(\sprintf('stream-persistence-test-%d@example.com', $counter));
|
||||
$owner->setPassword('irrelevant-hash');
|
||||
|
||||
$this->em->persist($owner);
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
public function testItPersistsAStreamLinkedToItsScenarioAndBucket(): void
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
|
|
|
|||
412
tests/Functional/StreamOwnerApiTest.php
Normal file
412
tests/Functional/StreamOwnerApiTest.php
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\Stream;
|
||||
use App\Entity\User;
|
||||
use App\Enum\StreamFrequency;
|
||||
use App\Enum\StreamType;
|
||||
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 StreamOwnerApiTest extends WebTestCase
|
||||
{
|
||||
private const EMAIL = 'stream-owner-api-test@example.com';
|
||||
private const PASSWORD = 'correct-horse-battery-staple';
|
||||
|
||||
private const OTHER_EMAIL = 'stream-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 Stream 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;
|
||||
}
|
||||
|
||||
private function persistStream(Scenario $scenario, string $name, ?Bucket $bucket = null): Stream
|
||||
{
|
||||
$stream = new Stream();
|
||||
$stream->setScenario($scenario);
|
||||
$stream->setBucket($bucket);
|
||||
$stream->setName($name);
|
||||
$stream->setAmount(1000);
|
||||
$stream->setType(StreamType::INCOME);
|
||||
$stream->setFrequency(StreamFrequency::MONTHLY);
|
||||
$stream->setStartDate(new \DateTimeImmutable('2026-01-01'));
|
||||
|
||||
$this->em->persist($stream);
|
||||
$this->em->flush();
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal valid Stream POST payload, scoped under the given scenario IRI
|
||||
* (and optionally a bucket IRI). Mirrors the entity's required fields:
|
||||
* name, amount, type, frequency, startDate, scenario.
|
||||
*
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
private function streamPayload(string $scenarioIri, ?string $bucketIri = null, string $name = 'Paycheck'): array
|
||||
{
|
||||
$payload = [
|
||||
'scenario' => $scenarioIri,
|
||||
'name' => $name,
|
||||
'amount' => 1000,
|
||||
'type' => StreamType::INCOME->value,
|
||||
'frequency' => StreamFrequency::MONTHLY->value,
|
||||
'startDate' => '2026-01-01',
|
||||
];
|
||||
|
||||
if (null !== $bucketIri) {
|
||||
$payload['bucket'] = $bucketIri;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function testGetItemOnAStreamOwnedBySomeoneElseReturnsNotFound(): void
|
||||
{
|
||||
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
|
||||
$othersStream = $this->persistStream($othersScenario, 'Their Paycheck');
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request('GET', '/api/streams/'.$othersStream->getId(), server: [
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
]);
|
||||
|
||||
self::assertSame(
|
||||
404,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'GET /api/streams/{id} for a stream whose scenario is owned by another user must return 404, not 403 — '
|
||||
.'no existence oracle for streams you do not own, scoped transitively via scenario.owner.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetCollectionOnlyReturnsStreamsWhoseScenarioIsOwnedByTheCaller(): void
|
||||
{
|
||||
$myScenario = $this->persistScenarioFor($this->caller, 'My Fund');
|
||||
$this->persistStream($myScenario, 'My Paycheck');
|
||||
|
||||
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
|
||||
$this->persistStream($othersScenario, 'Their Paycheck');
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request('GET', '/api/streams', server: [
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
]);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
self::assertSame(
|
||||
200,
|
||||
$response->getStatusCode(),
|
||||
'GET /api/streams 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 Paycheck',
|
||||
$names,
|
||||
'GET /api/streams must include streams whose scenario is owned by the authenticated caller.',
|
||||
);
|
||||
self::assertNotContains(
|
||||
'Their Paycheck',
|
||||
$names,
|
||||
'GET /api/streams must NOT include streams whose scenario is owned by another user, '
|
||||
.'even though the stream itself has no direct owner column — ownership is transitive via scenario.owner.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnonymousGetCollectionIsRejectedRatherThanReturningUnfilteredStreams(): void
|
||||
{
|
||||
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
|
||||
$this->persistStream($othersScenario, 'Their Paycheck');
|
||||
|
||||
$this->client->request('GET', '/api/streams', server: [
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
]);
|
||||
|
||||
self::assertSame(
|
||||
401,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'GET /api/streams 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 testAnonymousGetItemIsRejectedRatherThanResolvingStream(): void
|
||||
{
|
||||
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
|
||||
$othersStream = $this->persistStream($othersScenario, 'Their Paycheck');
|
||||
|
||||
$this->client->request('GET', '/api/streams/'.$othersStream->getId(), server: [
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
]);
|
||||
|
||||
self::assertSame(
|
||||
401,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'GET /api/streams/{id} without authentication must be rejected by the security layer.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PINS OBSERVED (PRE-#50-STORY-5) BEHAVIOUR — flagged for review, not a locked contract.
|
||||
*
|
||||
* Mirrors BucketOwnerApiTest::testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound.
|
||||
* A foreign scenario IRI is already unresolvable to a non-owner (Scenario's own
|
||||
* ownership extension, #50 Story 2), so API Platform's IRI denormalizer raises an
|
||||
* "Item not found" failure during deserialization, surfaced as 400 — BEFORE any
|
||||
* Stream-specific extension code runs. This test asserts the OBSERVED 400, not a
|
||||
* desired 404. If a 404 contract is required, it needs an explicit normalizing
|
||||
* guard — an implementation decision, not assumed here.
|
||||
*/
|
||||
public function testPostUnderAForeignScenarioCurrentlyReturnsBadRequest(): void
|
||||
{
|
||||
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/streams',
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode($this->streamPayload(
|
||||
'/api/scenarios/'.$othersScenario->getId(),
|
||||
name: 'Sneaky Stream',
|
||||
)),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
400,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST /api/streams 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(Stream::class)->findOneBy(['name' => 'Sneaky Stream']);
|
||||
|
||||
self::assertNull(
|
||||
$persisted,
|
||||
'A rejected POST under a foreign scenario must never persist a Stream.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE WRINKLE — Story 5's distinguishing case. Unlike Bucket (which only points at
|
||||
* a scenario), Stream POST can carry a SEPARATE bucket IRI. Even when the scenario
|
||||
* IRI belongs to the caller, a foreign bucket IRI must not be acceptable — otherwise
|
||||
* a non-owner could attach a stream to a bucket they don't own (cross-tenant write
|
||||
* via the optional bucket leg). This test pins WHATEVER status the app currently
|
||||
* returns; if it is 201, that is a real ownership gap — the bucket relation resolves
|
||||
* despite the caller not owning it, and the create must be rejected before persisting.
|
||||
*/
|
||||
public function testPostUnderOwnScenarioButForeignBucketIsRejected(): void
|
||||
{
|
||||
$myScenario = $this->persistScenarioFor($this->caller, 'My Fund');
|
||||
|
||||
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
|
||||
$othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1);
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/streams',
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode($this->streamPayload(
|
||||
'/api/scenarios/'.$myScenario->getId(),
|
||||
bucketIri: '/api/buckets/'.$othersBucket->getId(),
|
||||
name: 'Cross Tenant Stream',
|
||||
)),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
400,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST /api/streams under the caller\'s OWN scenario but referencing a bucket IRI owned by ANOTHER '
|
||||
.'user must be rejected, not created — a non-owner must not be able to attach a stream to someone '
|
||||
.'else\'s bucket. Pinning the OBSERVED 400 (not Stream-owned logic — an incidental side-effect of '
|
||||
.'BucketOwnerExtension making the foreign bucket IRI unresolvable during denormalization, same '
|
||||
.'mechanism as the foreign-scenario case above). If this ever regresses to 403, that is an '
|
||||
.'existence-oracle leak (confirms the bucket exists but isn\'t yours) — exactly what #50 exists to '
|
||||
.'prevent. If it regresses to 201, that is a real ownership gap: the bucket relation resolved '
|
||||
.'despite the caller not owning it.',
|
||||
);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Cross Tenant Stream']);
|
||||
|
||||
self::assertNull(
|
||||
$persisted,
|
||||
'A rejected POST referencing a foreign bucket must never persist a Stream.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPostUnderTheCallersOwnScenarioAndOwnBucketSucceeds(): void
|
||||
{
|
||||
$myScenario = $this->persistScenarioFor($this->caller, 'My Fund');
|
||||
$myBucket = $this->persistBucket($myScenario, 'My Rent', 1);
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/streams',
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode($this->streamPayload(
|
||||
'/api/scenarios/'.$myScenario->getId(),
|
||||
bucketIri: '/api/buckets/'.$myBucket->getId(),
|
||||
name: 'Paycheck',
|
||||
)),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
201,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST /api/streams under the caller\'s own scenario AND own bucket must succeed — the ownership '
|
||||
.'scoping must not regress the happy path.',
|
||||
);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Paycheck']);
|
||||
|
||||
self::assertNotNull(
|
||||
$persisted,
|
||||
'A successful POST /api/streams under the caller\'s own scenario and bucket must persist the new Stream.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPostUnderTheCallersOwnScenarioWithNoBucketSucceeds(): void
|
||||
{
|
||||
$myScenario = $this->persistScenarioFor($this->caller, 'My Fund');
|
||||
|
||||
$this->login();
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/streams',
|
||||
server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
'HTTP_ACCEPT' => 'application/ld+json',
|
||||
],
|
||||
content: json_encode($this->streamPayload(
|
||||
'/api/scenarios/'.$myScenario->getId(),
|
||||
name: 'Unassigned Income',
|
||||
)),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
201,
|
||||
$this->client->getResponse()->getStatusCode(),
|
||||
'POST /api/streams under the caller\'s own scenario with NO bucket (null is valid — bucket is '
|
||||
.'optional) must succeed.',
|
||||
);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Unassigned Income']);
|
||||
|
||||
self::assertNotNull(
|
||||
$persisted,
|
||||
'A successful POST /api/streams with a null bucket must persist the new Stream.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,15 +4,17 @@ namespace App\Tests\Repository;
|
|||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Repository\BucketRepository;
|
||||
use App\Tests\Concerns\CreatesUsers;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
final class BucketRepositoryTest extends KernelTestCase
|
||||
{
|
||||
use CreatesUsers;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private BucketRepository $repository;
|
||||
|
||||
|
|
@ -142,17 +144,9 @@ final class BucketRepositoryTest extends KernelTestCase
|
|||
|
||||
private function persistScenario(string $name = 'Household Budget'): Scenario
|
||||
{
|
||||
static $counter = 0;
|
||||
++$counter;
|
||||
|
||||
$owner = new User();
|
||||
$owner->setEmail(\sprintf('bucket-repository-test-%d@example.com', $counter));
|
||||
$owner->setPassword('irrelevant-hash');
|
||||
$this->em->persist($owner);
|
||||
|
||||
$scenario = new Scenario();
|
||||
$scenario->setName($name);
|
||||
$scenario->setOwner($owner);
|
||||
$scenario->setOwner($this->persistOwner());
|
||||
$this->em->persist($scenario);
|
||||
$this->em->flush();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ namespace App\Tests\Validator;
|
|||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use App\Tests\Concerns\CreatesUsers;
|
||||
use App\Validator\SingleOverflowPerScenario;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
|
@ -15,6 +15,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
|||
|
||||
final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||
{
|
||||
use CreatesUsers;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private ValidatorInterface $validator;
|
||||
|
||||
|
|
@ -25,20 +27,6 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
|||
$this->validator = self::getContainer()->get(ValidatorInterface::class);
|
||||
}
|
||||
|
||||
private function persistOwner(): User
|
||||
{
|
||||
static $counter = 0;
|
||||
++$counter;
|
||||
|
||||
$owner = new User();
|
||||
$owner->setEmail(\sprintf('single-overflow-per-scenario-test-%d@example.com', $counter));
|
||||
$owner->setPassword('irrelevant-hash');
|
||||
|
||||
$this->em->persist($owner);
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
public function testSecondOverflowBucketOnTheSameScenarioRaisesAViolation(): void
|
||||
{
|
||||
$scenario = new Scenario();
|
||||
|
|
|
|||
Loading…
Reference in a new issue