2026-06-24 23:26:46 +02:00
< ? php
namespace App\Tests\Functional ;
2026-07-05 09:38:52 +02:00
use App\Entity\Bucket ;
2026-06-25 19:59:40 +02:00
use App\Entity\Scenario ;
2026-06-24 23:26:46 +02:00
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.' ,
);
}
2026-07-05 09:38:52 +02:00
private function persistScenario ( 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 ;
}
2026-06-24 23:26:46 +02:00
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 ();
2026-06-25 19:59:40 +02:00
$persisted = $this -> em -> getRepository ( Scenario :: class ) -> findOneBy ([ 'name' => 'Holiday Fund' ]);
2026-06-24 23:26:46 +02:00
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 ();
2026-06-25 19:59:40 +02:00
$persisted = $this -> em -> getRepository ( Scenario :: class ) -> findOneBy ([ 'name' => 'Spoofed Owner Fund' ]);
2026-06-24 23:26:46 +02:00
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.' ,
);
}
2026-06-25 19:59:40 +02:00
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.' ,
);
}
2026-07-05 09:38:52 +02:00
public function testGetItemOnAScenarioOwnedBySomeoneElseReturnsNotFoundWithNoBucketLeak () : void
{
$othersScenario = $this -> persistScenario ( $this -> otherUser , 'Someone Else\'s Fund' );
$this -> persistBucket ( $othersScenario , 'Their Rent' , 1 );
$this -> persistBucket ( $othersScenario , 'Their Groceries' , 2 );
$this -> login ();
$this -> client -> request ( 'GET' , '/api/scenarios/' . $othersScenario -> getId (), server : [
'HTTP_ACCEPT' => 'application/ld+json' ,
]);
$response = $this -> client -> getResponse ();
self :: assertSame (
404 ,
$response -> getStatusCode (),
'GET /api/scenarios/{id} for a scenario owned by another user must return 404, even when it has buckets.' ,
);
$content = ( string ) $response -> getContent ();
self :: assertStringNotContainsString (
'Their Rent' ,
$content ,
'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.' ,
);
self :: assertStringNotContainsString (
'Their Groceries' ,
$content ,
'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.' ,
);
}
public function testEmbeddedBucketsContainOnlyThisScenariosBuckets () : void
{
$scenarioA = $this -> persistScenario ( $this -> caller , 'Scenario A' );
$this -> persistBucket ( $scenarioA , 'A Rent' , 1 );
$this -> persistBucket ( $scenarioA , 'A Groceries' , 2 );
$scenarioB = $this -> persistScenario ( $this -> caller , 'Scenario B' );
$this -> persistBucket ( $scenarioB , 'B Rent' , 1 );
$this -> persistBucket ( $scenarioB , 'B Groceries' , 2 );
$this -> login ();
$this -> client -> request ( 'GET' , '/api/scenarios/' . $scenarioA -> getId (), server : [
'HTTP_ACCEPT' => 'application/ld+json' ,
]);
$response = $this -> client -> getResponse ();
self :: assertSame ( 200 , $response -> getStatusCode ());
$body = json_decode (( string ) $response -> getContent (), true );
self :: assertIsArray ( $body , 'The item response must be a JSON-LD object.' );
self :: assertIsArray ( $body [ 'buckets' ] ? ? null );
$names = array_column ( $body [ 'buckets' ], 'name' );
self :: assertContains ( 'A Rent' , $names , 'Scenario A\'s item must embed its own buckets.' );
self :: assertContains ( 'A Groceries' , $names , 'Scenario A\'s item must embed its own buckets.' );
self :: assertNotContains (
'B Rent' ,
$names ,
'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both '
. 'scenarios are owned by the same caller.' ,
);
self :: assertNotContains (
'B Groceries' ,
$names ,
'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both '
. 'scenarios are owned by the same caller.' ,
);
}
2026-06-25 19:59:40 +02:00
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.' ,
);
}
2026-06-24 23:26:46 +02:00
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.' ,
);
}
}