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;
+ }
+}