269 lines
9.3 KiB
PHP
269 lines
9.3 KiB
PHP
<?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.',
|
|
);
|
|
}
|
|
}
|