test(commercial) : export fournisseurs — dedup F3 + gating SIREN via permission explicite (ERP-113)
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user