Compare commits
No commits in common. "473b88b54e6361f1b7048ffa60c7ae1507b632bf" and "468f60da7f13cc3d061c06c0a7b311010a4868cd" have entirely different histories.
473b88b54e
...
468f60da7f
23 changed files with 17 additions and 1515 deletions
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
|
||||||
*/
|
|
||||||
final class Version20260623183021 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE scenario ADD owner_id UUID NOT NULL');
|
|
||||||
$this->addSql('ALTER TABLE scenario ADD CONSTRAINT FK_3E45C8D87E3C61F9 FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
|
||||||
$this->addSql('CREATE INDEX IDX_3E45C8D87E3C61F9 ON scenario (owner_id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('ALTER TABLE scenario DROP CONSTRAINT FK_3E45C8D87E3C61F9');
|
|
||||||
$this->addSql('DROP INDEX IDX_3E45C8D87E3C61F9');
|
|
||||||
$this->addSql('ALTER TABLE scenario DROP owner_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Doctrine;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
||||||
use App\Entity\Bucket;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 filters on that join's `owner`.
|
|
||||||
*
|
|
||||||
* @see AbstractOwnerScopeExtension for the 404-not-403 mechanism.
|
|
||||||
*/
|
|
||||||
final class BucketOwnerExtension extends AbstractOwnerScopeExtension
|
|
||||||
{
|
|
||||||
protected function getResourceClass(): string
|
|
||||||
{
|
|
||||||
return Bucket::class;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function ownerAlias(
|
|
||||||
QueryBuilder $queryBuilder,
|
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
|
||||||
string $rootAlias,
|
|
||||||
): string {
|
|
||||||
$scenarioAlias = $queryNameGenerator->generateJoinAlias('scenario');
|
|
||||||
$queryBuilder->innerJoin(\sprintf('%s.scenario', $rootAlias), $scenarioAlias);
|
|
||||||
|
|
||||||
return $scenarioAlias;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Doctrine;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
||||||
use App\Entity\Scenario;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 extends AbstractOwnerScopeExtension
|
|
||||||
{
|
|
||||||
protected function getResourceClass(): string
|
|
||||||
{
|
|
||||||
return Scenario::class;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function ownerAlias(
|
|
||||||
QueryBuilder $queryBuilder,
|
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
|
||||||
string $rootAlias,
|
|
||||||
): string {
|
|
||||||
return $rootAlias;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,15 +24,6 @@ class Scenario
|
||||||
#[ORM\Column(type: 'uuid')]
|
#[ORM\Column(type: 'uuid')]
|
||||||
private UuidV7 $id;
|
private UuidV7 $id;
|
||||||
|
|
||||||
/**
|
|
||||||
* Set server-side by App\State\ScenarioOwnerProcessor on write; must be
|
|
||||||
* assigned via setOwner() before persist. The non-nullable type means
|
|
||||||
* getOwner() throws if read before that invariant is satisfied.
|
|
||||||
*/
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private User $owner;
|
|
||||||
|
|
||||||
#[ORM\Column(enumType: DistributionMode::class)]
|
#[ORM\Column(enumType: DistributionMode::class)]
|
||||||
private DistributionMode $distributionMode = DistributionMode::EVEN;
|
private DistributionMode $distributionMode = DistributionMode::EVEN;
|
||||||
|
|
||||||
|
|
@ -53,11 +44,6 @@ class Scenario
|
||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOwner(): User
|
|
||||||
{
|
|
||||||
return $this->owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDistributionMode(): DistributionMode
|
public function getDistributionMode(): DistributionMode
|
||||||
{
|
{
|
||||||
return $this->distributionMode;
|
return $this->distributionMode;
|
||||||
|
|
@ -73,13 +59,6 @@ class Scenario
|
||||||
return $this->description;
|
return $this->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOwner(User $owner): static
|
|
||||||
{
|
|
||||||
$this->owner = $owner;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDistributionMode(DistributionMode $distributionMode): static
|
public function setDistributionMode(DistributionMode $distributionMode): static
|
||||||
{
|
{
|
||||||
$this->distributionMode = $distributionMode;
|
$this->distributionMode = $distributionMode;
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,10 @@ use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\ApiResource\ProjectionPreviewInput;
|
use App\ApiResource\ProjectionPreviewInput;
|
||||||
use App\ApiResource\ProjectionPreviewOutput;
|
use App\ApiResource\ProjectionPreviewOutput;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Repository\BucketRepository;
|
use App\Repository\BucketRepository;
|
||||||
use App\Repository\ScenarioRepository;
|
use App\Repository\ScenarioRepository;
|
||||||
use App\Service\Allocation\AllocateIncome;
|
use App\Service\Allocation\AllocateIncome;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
|
@ -24,7 +22,6 @@ class PreviewProcessor implements ProcessorInterface
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ScenarioRepository $scenarioRepository,
|
private readonly ScenarioRepository $scenarioRepository,
|
||||||
private readonly BucketRepository $bucketRepository,
|
private readonly BucketRepository $bucketRepository,
|
||||||
private readonly Security $security,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,15 +43,6 @@ class PreviewProcessor implements ProcessorInterface
|
||||||
throw new NotFoundHttpException();
|
throw new NotFoundHttpException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// This operation is gated by `is_granted('ROLE_USER')` on the Preview
|
|
||||||
// resource, so the security pipeline guarantees a logged-in User here.
|
|
||||||
$currentUser = $this->security->getUser();
|
|
||||||
\assert($currentUser instanceof User);
|
|
||||||
|
|
||||||
if (!$currentUser->getId()->equals($scenario->getOwner()->getId())) {
|
|
||||||
throw new NotFoundHttpException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildOutput($scenario, $data->amount);
|
return $this->buildOutput($scenario, $data->amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Scenario;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamps the authenticated user as the owner of a Scenario on write,
|
|
||||||
* then hands off to the core Doctrine persist processor.
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<object, object|null>
|
|
||||||
*/
|
|
||||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
final class ScenarioOwnerProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param ProcessorInterface<object, object|null> $decorated
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
#[AutowireDecorated]
|
|
||||||
private readonly ProcessorInterface $decorated,
|
|
||||||
private readonly Security $security,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function process(
|
|
||||||
mixed $data,
|
|
||||||
Operation $operation,
|
|
||||||
array $uriVariables = [],
|
|
||||||
array $context = [],
|
|
||||||
): mixed {
|
|
||||||
if ($data instanceof Scenario) {
|
|
||||||
// The Scenario Post operation is gated by `is_granted('ROLE_USER')`
|
|
||||||
// (see #[ApiResource] on App\Entity\Scenario), so by the time this
|
|
||||||
// processor runs the security pipeline guarantees a logged-in User.
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
\assert($user instanceof User);
|
|
||||||
|
|
||||||
$data->setOwner($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,6 @@ use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Tests\Concerns\CreatesUsers;
|
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
@ -14,8 +13,6 @@ use Symfony\Component\Uid\UuidV7;
|
||||||
|
|
||||||
final class BucketPersistenceTest extends KernelTestCase
|
final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
use CreatesUsers;
|
|
||||||
|
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -28,7 +25,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -99,7 +95,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -139,7 +134,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -180,7 +174,6 @@ final class BucketPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Tests\Entity;
|
|
||||||
|
|
||||||
use App\Entity\Scenario;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
||||||
|
|
||||||
final class ScenarioOwnerTest extends KernelTestCase
|
|
||||||
{
|
|
||||||
private EntityManagerInterface $em;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
self::bootKernel();
|
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testOwnerRoundTripsThroughPersistence(): void
|
|
||||||
{
|
|
||||||
$owner = new User();
|
|
||||||
$owner->setEmail('scenario-owner-test@example.com');
|
|
||||||
$owner->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$this->em->persist($owner);
|
|
||||||
|
|
||||||
$scenario = new Scenario();
|
|
||||||
$scenario->setName('Household Budget');
|
|
||||||
$scenario->setOwner($owner);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$scenarioId = $scenario->getId();
|
|
||||||
$ownerId = $owner->getId();
|
|
||||||
|
|
||||||
$this->em->clear();
|
|
||||||
|
|
||||||
$reloaded = $this->em->find(Scenario::class, $scenarioId);
|
|
||||||
|
|
||||||
self::assertNotNull($reloaded, 'Scenario must be retrievable from the database after an identity-map clear.');
|
|
||||||
self::assertInstanceOf(User::class, $reloaded->getOwner());
|
|
||||||
self::assertTrue(
|
|
||||||
$ownerId->equals($reloaded->getOwner()->getId()),
|
|
||||||
'The owner set before persistence must survive a flush + clear + reload round-trip.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,15 +4,12 @@ namespace App\Tests\Entity;
|
||||||
|
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Enum\DistributionMode;
|
use App\Enum\DistributionMode;
|
||||||
use App\Tests\Concerns\CreatesUsers;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
use Symfony\Component\Uid\UuidV7;
|
use Symfony\Component\Uid\UuidV7;
|
||||||
|
|
||||||
final class ScenarioPersistenceTest extends KernelTestCase
|
final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
use CreatesUsers;
|
|
||||||
|
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -25,7 +22,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -46,7 +42,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Default Mode Scenario');
|
$scenario->setName('Default Mode Scenario');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -70,7 +65,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Priority Mode Scenario');
|
$scenario->setName('Priority Mode Scenario');
|
||||||
$scenario->setDistributionMode(DistributionMode::PRIORITY);
|
$scenario->setDistributionMode(DistributionMode::PRIORITY);
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -99,7 +93,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Described Scenario');
|
$scenario->setName('Described Scenario');
|
||||||
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
|
$scenario->setDescription('Covers rent, groceries, and the emergency buffer.');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -118,7 +111,6 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Undescribed Scenario');
|
$scenario->setName('Undescribed Scenario');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@ use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Enum\StreamFrequency;
|
use App\Enum\StreamFrequency;
|
||||||
use App\Enum\StreamType;
|
use App\Enum\StreamType;
|
||||||
use App\Tests\Concerns\CreatesUsers;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
final class StreamPersistenceTest extends KernelTestCase
|
final class StreamPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
use CreatesUsers;
|
|
||||||
|
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -29,7 +26,6 @@ final class StreamPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
|
|
||||||
$bucket = new Bucket();
|
$bucket = new Bucket();
|
||||||
|
|
@ -123,7 +119,6 @@ final class StreamPersistenceTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ final class BucketApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -37,11 +36,11 @@ final class BucketApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($this->user);
|
$this->em->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +62,6 @@ final class BucketApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
<?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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,12 +25,8 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
private const EMAIL = 'projection-preview-api-test@example.com';
|
private const EMAIL = 'projection-preview-api-test@example.com';
|
||||||
private const PASSWORD = 'correct-horse-battery-staple';
|
private const PASSWORD = 'correct-horse-battery-staple';
|
||||||
|
|
||||||
private const OTHER_EMAIL = 'projection-preview-api-other@example.com';
|
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
private User $otherUser;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -45,16 +41,11 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
$this->em->persist($this->user);
|
|
||||||
|
|
||||||
$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->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,11 +63,10 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistScenario(string $name = 'Household Budget', ?User $owner = null): Scenario
|
private function persistScenario(string $name = 'Household Budget'): Scenario
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($owner ?? $this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
@ -265,54 +255,6 @@ final class ProjectionPreviewApiTest extends WebTestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPreviewForAScenarioOwnedBySomeoneElseReturnsNotFound(): void
|
|
||||||
{
|
|
||||||
$othersScenario = $this->persistScenario('Someone Else\'s Fund', $this->otherUser);
|
|
||||||
|
|
||||||
$this->login();
|
|
||||||
|
|
||||||
$this->client->request(
|
|
||||||
'POST',
|
|
||||||
$this->previewUri($othersScenario),
|
|
||||||
server: [
|
|
||||||
'CONTENT_TYPE' => 'application/ld+json',
|
|
||||||
'HTTP_ACCEPT' => 'application/ld+json',
|
|
||||||
],
|
|
||||||
content: json_encode(['amount' => 8000]),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
404,
|
|
||||||
$this->client->getResponse()->getStatusCode(),
|
|
||||||
'POST .../projections/preview for a scenario owned by another user must return 404, not 403 — '
|
|
||||||
.'no existence oracle for scenarios you do not own, matching the rest of #50.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPreviewForTheCallersOwnScenarioStillSucceeds(): void
|
|
||||||
{
|
|
||||||
$myScenario = $this->persistScenario('My Fund', $this->user);
|
|
||||||
|
|
||||||
$this->login();
|
|
||||||
|
|
||||||
$this->client->request(
|
|
||||||
'POST',
|
|
||||||
$this->previewUri($myScenario),
|
|
||||||
server: [
|
|
||||||
'CONTENT_TYPE' => 'application/ld+json',
|
|
||||||
'HTTP_ACCEPT' => 'application/ld+json',
|
|
||||||
],
|
|
||||||
content: json_encode(['amount' => 8000]),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
200,
|
|
||||||
$this->client->getResponse()->getStatusCode(),
|
|
||||||
'POST .../projections/preview for the caller\'s own scenario must still succeed — ownership '
|
|
||||||
.'enforcement must not regress the happy path.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPreviewWithMissingAmountIsRejectedAsUnprocessable(): void
|
public function testPreviewWithMissingAmountIsRejectedAsUnprocessable(): void
|
||||||
{
|
{
|
||||||
$scenario = $this->persistScenario();
|
$scenario = $this->persistScenario();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -33,11 +32,11 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($this->user);
|
$this->em->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +58,6 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Tests\Functional;
|
|
||||||
|
|
||||||
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 ScenarioOwnerApiTest extends WebTestCase
|
|
||||||
{
|
|
||||||
private const EMAIL = 'scenario-owner-api-test@example.com';
|
|
||||||
private const PASSWORD = 'correct-horse-battery-staple';
|
|
||||||
|
|
||||||
private const OTHER_EMAIL = 'scenario-owner-api-spoof-target@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 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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,6 @@ final class StreamApiTest extends WebTestCase
|
||||||
|
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -37,11 +36,11 @@ final class StreamApiTest extends WebTestCase
|
||||||
]);
|
]);
|
||||||
$hasher = new UserPasswordHasher($factory);
|
$hasher = new UserPasswordHasher($factory);
|
||||||
|
|
||||||
$this->user = new User();
|
$user = new User();
|
||||||
$this->user->setEmail(self::EMAIL);
|
$user->setEmail(self::EMAIL);
|
||||||
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
|
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
|
||||||
|
|
||||||
$this->em->persist($this->user);
|
$this->em->persist($user);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +62,6 @@ final class StreamApiTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->user);
|
|
||||||
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
|
||||||
|
|
@ -1,412 +0,0 @@
|
||||||
<?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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,14 +7,11 @@ use App\Entity\Scenario;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Repository\BucketRepository;
|
use App\Repository\BucketRepository;
|
||||||
use App\Tests\Concerns\CreatesUsers;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
final class BucketRepositoryTest extends KernelTestCase
|
final class BucketRepositoryTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
use CreatesUsers;
|
|
||||||
|
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private BucketRepository $repository;
|
private BucketRepository $repository;
|
||||||
|
|
||||||
|
|
@ -146,7 +143,6 @@ final class BucketRepositoryTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName($name);
|
$scenario->setName($name);
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@
|
||||||
|
|
||||||
namespace App\Tests\State;
|
namespace App\Tests\State;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\ApiResource\ProjectionPreviewInput;
|
|
||||||
use App\ApiResource\ProjectionPreviewOutput;
|
use App\ApiResource\ProjectionPreviewOutput;
|
||||||
use App\Entity\Bucket;
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Repository\BucketRepository;
|
use App\Repository\BucketRepository;
|
||||||
|
|
@ -15,8 +12,6 @@ use App\Repository\ScenarioRepository;
|
||||||
use App\State\PreviewProcessor;
|
use App\State\PreviewProcessor;
|
||||||
use PHPUnit\Framework\MockObject\Stub;
|
use PHPUnit\Framework\MockObject\Stub;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure-unit complement to tests/Functional/ProjectionPreviewApiTest.php.
|
* Pure-unit complement to tests/Functional/ProjectionPreviewApiTest.php.
|
||||||
|
|
@ -50,7 +45,6 @@ final class PreviewProcessorTest extends TestCase
|
||||||
$this->provider = new PreviewProcessor(
|
$this->provider = new PreviewProcessor(
|
||||||
$this->createStub(ScenarioRepository::class),
|
$this->createStub(ScenarioRepository::class),
|
||||||
$this->bucketRepository,
|
$this->bucketRepository,
|
||||||
$this->createStub(Security::class),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,51 +127,6 @@ final class PreviewProcessorTest extends TestCase
|
||||||
self::assertInstanceOf(ProjectionPreviewOutput::class, $output);
|
self::assertInstanceOf(ProjectionPreviewOutput::class, $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* SEAM PINNED (#50 Story 4): unlike Stories 1-3, the preview path does NOT go
|
|
||||||
* through a Doctrine query extension — it goes through this processor's own
|
|
||||||
* `process()`. Ownership therefore has to be enforced HERE, not via a query
|
|
||||||
* filter. This test calls `process()` directly (not `buildOutput()`) because
|
|
||||||
* the owner check needs the full scenario-resolution flow, including the
|
|
||||||
* current authenticated user — pinning the behavioural outcome regardless of
|
|
||||||
* exactly where inside process() the comparison lives.
|
|
||||||
*
|
|
||||||
* Convention followed: `Symfony\Bundle\SecurityBundle\Security` is the
|
|
||||||
* project's established way to obtain the current user in a processor (see
|
|
||||||
* `App\State\ScenarioOwnerProcessor`) — assumed here as the collaborator the
|
|
||||||
* processor will be constructed with, consistent with that existing pattern.
|
|
||||||
*/
|
|
||||||
public function testProcessThrowsNotFoundWhenTheScenarioIsOwnedBySomeoneElse(): void
|
|
||||||
{
|
|
||||||
$owner = (new User())->setEmail('owner@example.com')->setPassword('irrelevant-hash');
|
|
||||||
$intruder = (new User())->setEmail('intruder@example.com')->setPassword('irrelevant-hash');
|
|
||||||
|
|
||||||
$scenario = $this->makeScenario()->setOwner($owner);
|
|
||||||
|
|
||||||
$scenarioRepository = $this->createStub(ScenarioRepository::class);
|
|
||||||
$scenarioRepository->method('find')->willReturn($scenario);
|
|
||||||
|
|
||||||
$security = $this->createStub(Security::class);
|
|
||||||
$security->method('getUser')->willReturn($intruder);
|
|
||||||
|
|
||||||
$processor = new PreviewProcessor(
|
|
||||||
$scenarioRepository,
|
|
||||||
$this->createStub(BucketRepository::class),
|
|
||||||
$security,
|
|
||||||
);
|
|
||||||
|
|
||||||
$input = new ProjectionPreviewInput();
|
|
||||||
$input->amount = 1000;
|
|
||||||
|
|
||||||
$this->expectException(NotFoundHttpException::class);
|
|
||||||
|
|
||||||
$processor->process(
|
|
||||||
$input,
|
|
||||||
new Post(),
|
|
||||||
['scenario' => (string) $scenario->getId()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeScenario(): Scenario
|
private function makeScenario(): Scenario
|
||||||
{
|
{
|
||||||
return (new Scenario())->setName('Household Budget');
|
return (new Scenario())->setName('Household Budget');
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Enum\BucketAllocationType;
|
use App\Enum\BucketAllocationType;
|
||||||
use App\Enum\BucketType;
|
use App\Enum\BucketType;
|
||||||
use App\Tests\Concerns\CreatesUsers;
|
|
||||||
use App\Validator\SingleOverflowPerScenario;
|
use App\Validator\SingleOverflowPerScenario;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
@ -15,8 +14,6 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
final class SingleOverflowPerScenarioTest extends KernelTestCase
|
final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
use CreatesUsers;
|
|
||||||
|
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private ValidatorInterface $validator;
|
private ValidatorInterface $validator;
|
||||||
|
|
||||||
|
|
@ -31,7 +28,6 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -58,7 +54,6 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
@ -82,7 +77,6 @@ final class SingleOverflowPerScenarioTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
$scenario = new Scenario();
|
$scenario = new Scenario();
|
||||||
$scenario->setName('Household Budget');
|
$scenario->setName('Household Budget');
|
||||||
$scenario->setOwner($this->persistOwner());
|
|
||||||
$this->em->persist($scenario);
|
$this->em->persist($scenario);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue