From 86415bef7c7631fdb9bef15bb55b897c28221287 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 8 Jun 2026 10:08:07 +0200 Subject: [PATCH] =?UTF-8?q?test(commercial)=20:=20export=20fournisseurs=20?= =?UTF-8?q?=E2=80=94=20dedup=20F3=20+=20gating=20SIREN=20via=20permission?= =?UTF-8?q?=20explicite=20(ERP-113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/SupplierExportControllerTest.php | 59 ++++++++++++++++++- tests/Module/Core/Api/AbstractApiTestCase.php | 48 +++++++++++---- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/tests/Module/Commercial/Api/SupplierExportControllerTest.php b/tests/Module/Commercial/Api/SupplierExportControllerTest.php index ca00e63..f39f741 100644 --- a/tests/Module/Commercial/Api/SupplierExportControllerTest.php +++ b/tests/Module/Commercial/Api/SupplierExportControllerTest.php @@ -15,8 +15,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory; * Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des * archives par defaut, respect du filtre ?search, peuplement des colonnes * contact principal / categories / sites, gating de la colonne SIREN selon - * commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view, - * 401 anonyme. + * commercial.suppliers.accounting.view (admin ET user minimal a permission + * explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne), + * 403 sans commercial.suppliers.view, 401 anonyme. * * @internal */ @@ -178,6 +179,60 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase self::assertStringNotContainsString('987654321', $this->flatten($grid)); } + /** + * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) : + * un user minimal portant uniquement commercial.suppliers.view + + * commercial.suppliers.accounting.view voit bien la colonne SIREN et sa + * valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui + * ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). + * Le pendant negatif (sans accounting.view -> colonne absente) est couvert par + * testSirenColumnAbsentWithoutAccountingView. + */ + public function testSirenColumnPresentForMinimalUserWithAccountingView(): void + { + // Seed via admin, puis relecture par un user non-admin a 2 permissions. + $this->createAdminClient(); + $supplier = $this->seedSupplier('Gated Siren Co'); + $em = $this->getEm(); + $supplier->setSiren('456789123'); + $em->flush(); + + $creds = $this->createUserWithPermissions([ + 'commercial.suppliers.view', + 'commercial.suppliers.accounting.view', + ]); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('456789123', $this->flatten($grid)); + } + + /** + * Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie + * par la jointure (selection/hydratation des collections) ; l'export doit le + * rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on + * assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ». + */ + public function testExportDeduplicatesSupplierWithMultipleCategories(): void + { + $client = $this->createAdminClient(); + $supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT'); + // 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10). + $supplier->addCategory($this->supplierCategory('GROSSISTE')); + $this->getEm()->flush(); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name)); + self::assertSame( + 1, + $occurrences, + 'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).', + ); + } + public function testForbiddenWithoutSuppliersViewPermission(): void { $creds = $this->createUserWithPermission('core.users.view'); diff --git a/tests/Module/Core/Api/AbstractApiTestCase.php b/tests/Module/Core/Api/AbstractApiTestCase.php index 9f7bcc2..a258f32 100644 --- a/tests/Module/Core/Api/AbstractApiTestCase.php +++ b/tests/Module/Core/Api/AbstractApiTestCase.php @@ -90,6 +90,26 @@ abstract class AbstractApiTestCase extends ApiTestCase * @return array{username: string, password: string} Les identifiants pour authenticatedClient() */ protected function createUserWithPermission(string $permissionCode): array + { + return $this->createUserWithPermissions([$permissionCode]); + } + + /** + * Variante multi-permissions de {@see createUserWithPermission()} : cree un + * utilisateur non-admin portant PLUSIEURS permissions via un unique role + * jetable. Utile pour prouver qu'une combinaison precise de permissions + * (sans le bypass admin) suffit a debloquer un comportement — ex. la colonne + * SIREN de l'export, gatee par accounting.view EN PLUS de suppliers.view. + * + * Memes garanties que le singulier : suffixe aleatoire, password "testpass", + * rattachement a tous les sites, echec explicite si une permission est + * introuvable en base. + * + * @param list $permissionCodes codes des permissions a accorder + * + * @return array{username: string, password: string} identifiants pour authenticatedClient() + */ + protected function createUserWithPermissions(array $permissionCodes): array { if (!self::$kernel) { self::bootKernel(); @@ -97,17 +117,6 @@ abstract class AbstractApiTestCase extends ApiTestCase $em = $this->getEm(); - /** @var null|Permission $permission */ - $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]); - - self::assertNotNull( - $permission, - sprintf( - 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.', - $permissionCode, - ), - ); - $suffix = substr(bin2hex(random_bytes(4)), 0, 8); $username = 'testuser_'.$suffix; $password = 'testpass'; @@ -116,7 +125,22 @@ abstract class AbstractApiTestCase extends ApiTestCase $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); - $role->addPermission($permission); + + foreach ($permissionCodes as $permissionCode) { + /** @var null|Permission $permission */ + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]); + + self::assertNotNull( + $permission, + sprintf( + 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.', + $permissionCode, + ), + ); + + $role->addPermission($permission); + } + $em->persist($role); $user = new User();