test(commercial) : export fournisseurs — dedup F3 + gating SIREN via permission explicite (ERP-113)

This commit is contained in:
Matthieu
2026-06-08 10:08:07 +02:00
parent 5c8318781a
commit 86415bef7c
2 changed files with 93 additions and 14 deletions
@@ -15,8 +15,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des * Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes * archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon * contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view, * commercial.suppliers.accounting.view (admin ET user minimal a permission
* 401 anonyme. * explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
* 403 sans commercial.suppliers.view, 401 anonyme.
* *
* @internal * @internal
*/ */
@@ -178,6 +179,60 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
self::assertStringNotContainsString('987654321', $this->flatten($grid)); 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 public function testForbiddenWithoutSuppliersViewPermission(): void
{ {
$creds = $this->createUserWithPermission('core.users.view'); $creds = $this->createUserWithPermission('core.users.view');
+36 -12
View File
@@ -90,6 +90,26 @@ abstract class AbstractApiTestCase extends ApiTestCase
* @return array{username: string, password: string} Les identifiants pour authenticatedClient() * @return array{username: string, password: string} Les identifiants pour authenticatedClient()
*/ */
protected function createUserWithPermission(string $permissionCode): array 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<string> $permissionCodes codes des permissions a accorder
*
* @return array{username: string, password: string} identifiants pour authenticatedClient()
*/
protected function createUserWithPermissions(array $permissionCodes): array
{ {
if (!self::$kernel) { if (!self::$kernel) {
self::bootKernel(); self::bootKernel();
@@ -97,17 +117,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em = $this->getEm(); $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); $suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'testuser_'.$suffix; $username = 'testuser_'.$suffix;
$password = 'testpass'; $password = 'testpass';
@@ -116,7 +125,22 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); $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); $em->persist($role);
$user = new User(); $user = new User();