Merge remote-tracking branch 'origin/feat/module-site-backend' into feat/admin-tables-filter-pagination

# Conflicts:
#	frontend/modules/sites/pages/admin/sites.vue
This commit is contained in:
2026-04-20 17:04:04 +02:00
24 changed files with 611 additions and 82 deletions

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -123,6 +124,19 @@ abstract class AbstractApiTestCase extends ApiTestCase
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role);
// Le helper attache le user jetable a tous les sites existants pour
// neutraliser le filtrage par UserSiteScopedExtension : la plupart
// des tests assume une visibilite globale sur les users cibles. Les
// tests qui valident le comportement "sans sites" doivent creer leur
// user a la main (pas via ce helper).
$siteRepository = $em->getRepository(Site::class);
if (null !== $siteRepository) {
foreach ($siteRepository->findAll() as $site) {
$user->addSite($site);
}
}
$em->persist($user);
$em->flush();

View File

@@ -7,6 +7,7 @@ namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
@@ -41,11 +42,18 @@ final class UserRbacApiTest extends AbstractApiTestCase
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// User cible standard (non admin).
// User cible standard (non admin). On lui attache tous les sites
// fixtures pour rester visible depuis les callers non-admin munis de
// sites (cf. UserSiteScopedExtension qui filtre `/api/users` par
// intersection de sites). Sans cela, un user `core.users.manage`
// sans site commun avec test_target recevrait un 404 sur le PATCH.
$target = new User();
$target->setUsername('test_target');
$target->setIsAdmin(false);
$target->setPassword($hasher->hashPassword($target, 'secret'));
foreach ($em->getRepository(Site::class)->findAll() as $site) {
$target->addSite($site);
}
$em->persist($target);
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).

View File

@@ -50,6 +50,14 @@ final class UserRbacProcessorTest extends TestCase
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
// wrapInTransaction doit executer reellement la closure pour que le
// resultat de persistProcessor->process() soit capture dans $result.
// Sans ce stub, la closure n'est jamais invoquee et $result reste null.
$this->entityManager
->method('wrapInTransaction')
->willReturnCallback(static fn (callable $fn) => $fn())
;
$this->processor = new UserRbacProcessor(
$this->persistProcessor,
$this->entityManager,

View File

@@ -39,7 +39,14 @@ final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
public function testUserCannotSwitchToUnauthorizedSite(): void
{
// alice n'a que Chatellerault. Tenter Pommevic → 403.
// alice n'a que Chatellerault. Tenter Pommevic → 400 (anti-enumeration).
//
// Depuis l'ajout de SiteCollectionScopedExtension, les sites hors
// du scope de l'user sont filtres a la source : l'IriConverter ne
// peut pas resoudre `/api/sites/{id}` pour un site non autorise et
// leve 400 "Item not found". Reponse identique a "site inexistant",
// ce qui empeche l'enumeration des ids de sites tiers. Avant la PR
// scope, le processor traduisait SiteNotAuthorizedException → 403.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
@@ -50,7 +57,7 @@ final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseStatusCodeSame(403);
self::assertResponseStatusCodeSame(400);
}
public function testSwitchWithMissingSiteFieldReturns400(): void

View File

@@ -11,8 +11,10 @@ use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\SiteAwareInjectionProcessor;
use App\Shared\Domain\Contract\SiteAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -125,20 +127,27 @@ final class SiteAwareInjectionProcessorTest extends TestCase
$provider = $this->createStub(CurrentSiteProviderInterface::class);
$provider->method('get')->willReturn($currentSite);
return new SiteAwareInjectionProcessor($inner, $provider);
// Stub Security : bypass_scope = true par defaut pour preserver le
// comportement des tests historiques (pas de validation cross-site).
// Les tests dedies a la validation cross-site instancient leur propre
// Security via un helper dedie.
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturn(true);
return new SiteAwareInjectionProcessor($inner, $provider, $security);
}
private function makeSiteAwareStub(?Site $initialSite): SiteAwareInterface
{
return new class($initialSite) implements SiteAwareInterface {
public function __construct(private ?Site $site) {}
public function __construct(private ?SiteInterface $site) {}
public function getSite(): ?Site
public function getSite(): ?SiteInterface
{
return $this->site;
}
public function setSite(Site $site): void
public function setSite(SiteInterface $site): void
{
$this->site = $site;
}