2026-06-24 23:26:46 +02:00
< ? php
namespace App\Tests\Functional ;
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.' ,
);
}
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.' ,
);
}
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.' ,
);
}
}