diff --git a/composer.json b/composer.json index de7bace..a3a9b4f 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", - "symfony/http-client": "8.0.*", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "8.0.*", @@ -90,6 +89,8 @@ "require-dev": { "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.94", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.0", + "symfony/browser-kit": "8.0.*", + "symfony/http-client": "8.0.*" } } diff --git a/composer.lock b/composer.lock index 7eded7a..43d0aeb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bfd26e903d79f710cfe95452c05f2a25", + "content-hash": "75f8e672f2a401290886fbcf01befd3f", "packages": [ { "name": "api-platform/doctrine-common", @@ -4988,180 +4988,6 @@ ], "time": "2026-03-30T15:14:47+00:00" }, - { - "name": "symfony/http-client", - "version": "v8.0.8", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "psr/log": "^1|^2|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "amphp/amp": "<3", - "php-http/discovery": "<1.15" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/http-client": "^5.3.2", - "amphp/http-tunnel": "^2.0", - "guzzlehttp/promises": "^1.4|^2.0", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/cache": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", - "homepage": "https://symfony.com", - "keywords": [ - "http" - ], - "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.8" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-30T15:14:47+00:00" - }, - { - "name": "symfony/http-client-contracts", - "version": "v3.6.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to HTTP clients", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-04-29T11:18:49+00:00" - }, { "name": "symfony/http-foundation", "version": "v8.0.8", @@ -11018,6 +10844,322 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "284ace90732b445b027728b5e0eec6418a17a364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", + "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-client", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", + "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/process", "version": "v8.0.8", diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 4f32b21..7558fb2 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,6 +1,13 @@ api_platform: title: Coltura API version: 1.0.0 + # Scan des modules pour decouvrir les classes ApiResource et ApiFilter. + # Sans ces paths, le compile pass d'API Platform ne declare pas les + # services de filtres annotes (les filtres etaient silencieusement + # ignores sur Permission — cf. ticket #344). + mapping: + paths: + - '%kernel.project_dir%/src/Module/Core/Domain/Entity' formats: jsonld: ['application/ld+json'] json: ['application/json'] diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 22bd879..eb794bd 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -15,6 +15,7 @@ + diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index e5e92a0..83a3b06 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -4,10 +4,33 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; +use Symfony\Component\Serializer\Attribute\Groups; +#[ApiResource( + operations: [ + new GetCollection( + normalizationContext: ['groups' => ['permission:read']], + // TODO ticket #345 : remplacer par is_granted('core.permissions.view') + security: "is_granted('ROLE_ADMIN')", + ), + new Get( + normalizationContext: ['groups' => ['permission:read']], + // TODO ticket #345 : remplacer par is_granted('core.permissions.view') + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])] +#[ApiFilter(BooleanFilter::class, properties: ['orphan'])] #[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] #[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])] @@ -18,18 +41,23 @@ class Permission #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + #[Groups(['permission:read'])] private ?int $id = null; #[ORM\Column(length: 255)] + #[Groups(['permission:read'])] private string $code; #[ORM\Column(length: 255)] + #[Groups(['permission:read'])] private string $label; #[ORM\Column(length: 100)] + #[Groups(['permission:read'])] private string $module; #[ORM\Column(options: ['default' => false])] + #[Groups(['permission:read'])] private bool $orphan = false; /** diff --git a/tests/Module/Core/Api/PermissionApiTest.php b/tests/Module/Core/Api/PermissionApiTest.php new file mode 100644 index 0000000..0e7fbe2 --- /dev/null +++ b/tests/Module/Core/Api/PermissionApiTest.php @@ -0,0 +1,185 @@ +em = self::getContainer()->get('doctrine')->getManager(); + + // Nettoyage defensif au cas ou un run precedent aurait laisse des restes. + $this->cleanupTestPermissions(); + + // Donnees de test : deux permissions "core" dont une orpheline, + // plus une permission d'un autre module pour verifier le filtre. + $p1 = new Permission('test.core.users.view', 'View users (test)', 'core'); + $p2 = new Permission('test.core.users.manage', 'Manage users (test)', 'core'); + $p3 = new Permission('test.commercial.clients.view', 'View clients (test)', 'commercial'); + $p2->markOrphan(); + + $this->em->persist($p1); + $this->em->persist($p2); + $this->em->persist($p3); + $this->em->flush(); + $this->em->clear(); + } + + protected function tearDown(): void + { + $this->cleanupTestPermissions(); + parent::tearDown(); + } + + public function testGetCollectionAsAdminReturns200(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + self::assertGreaterThanOrEqual(3, $data['totalItems']); + } + + public function testCollectionFilterByModule(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions', [ + 'query' => ['module' => 'core'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertSame('core', $item['module']); + } + // Doit contenir au moins nos deux permissions core de test. + $codes = array_column($data['member'], 'code'); + self::assertContains('test.core.users.view', $codes); + self::assertContains('test.core.users.manage', $codes); + self::assertNotContains('test.commercial.clients.view', $codes); + } + + public function testCollectionFilterByOrphanFalse(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions', [ + 'query' => ['orphan' => 'false'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertFalse($item['orphan']); + } + $codes = array_column($data['member'], 'code'); + self::assertContains('test.core.users.view', $codes); + self::assertNotContains('test.core.users.manage', $codes); + } + + public function testGetItemAsAdminReturnsAllReadFields(): void + { + /** @var null|Permission $permission */ + $permission = $this->em->getRepository(Permission::class) + ->findOneBy(['code' => 'test.core.users.view']) + ; + self::assertNotNull($permission); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions/'.$permission->getId()); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame($permission->getId(), $data['id']); + self::assertSame('test.core.users.view', $data['code']); + self::assertSame('View users (test)', $data['label']); + self::assertSame('core', $data['module']); + self::assertFalse($data['orphan']); + } + + public function testPostIsMethodNotAllowed(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('POST', '/api/permissions', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'test.foo.bar.baz', 'label' => 'Foo', 'module' => 'foo'], + ]); + + self::assertResponseStatusCodeSame(405); + } + + public function testUnauthenticatedReturns401(): void + { + $client = self::createClient(); + $client->request('GET', '/api/permissions'); + + self::assertResponseStatusCodeSame(401); + } + + public function testNonAdminReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/permissions'); + + self::assertResponseStatusCodeSame(403); + } + + private function cleanupTestPermissions(): void + { + $this->em->createQuery( + 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' + )->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute(); + } + + /** + * Cree un client authentifie via /login_check. La configuration du projet + * pose le JWT dans un cookie HTTP-only `BEARER` (cf. lexik_jwt_authentication.yaml) + * et retire le token du body de reponse ; le client BrowserKit persiste + * automatiquement le cookie pour les requetes suivantes. + */ + private function authenticatedClient(string $username, string $password): Client + { + $client = self::createClient(); + $response = $client->request('POST', '/login_check', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['username' => $username, 'password' => $password], + ]); + + self::assertContains( + $response->getStatusCode(), + [200, 204], + 'Login failed for '.$username.': '.$response->getStatusCode(), + ); + + return $client; + } +}