Files
Starseed/tests/Module/Technique/Api/ProviderRbacGatingTest.php
T
Matthieu 0ca1fb159a feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134)
Coeur API du repertoire prestataires (M3), jumeau du M2 fournisseurs :

- ProviderProvider : liste paginee (Paginator ORM), filtres
  search/categoryCode/siteId/includeArchived, tri companyName ASC,
  exclusion archives + soft-deletes (RG-3.16). Cloisonnement par site
  pilote par l'utilisateur (RG-3.17 / § 2.13) : liste restreinte au
  currentSite avant pagination (totalItems = perimetre), detail hors
  perimetre -> 404, bypass via sites.bypass_scope.
- ProviderProcessor : normalisation companyName (RG-3.11), POST formulaire
  principal (companyName + categories + sites), PATCH partiels par groupe
  en mode strict (RG-3.15, 403 sur tout le payload), archivage
  (RG-3.13/3.14), 409 doublon de nom (RG-3.10), garde d'ecriture cloisonnee
  des sites (RG-3.03/3.17, 422 sur sites pour les users sites.read_ref).
- ProviderReadGroupContextBuilder : gating comptabilite par AJOUT du groupe
  provider:read:accounting si accounting.view (jamais par retrait).
- ProviderFieldNormalizer : miroir SupplierFieldNormalizer.
- ApiResource cable (provider + processor) sur l'entite Provider.

Tests : ProviderApiTest, ProviderListTest, ProviderRbacGatingTest,
ProviderSiteScopeTest (26 tests). Suite complete verte (612 tests).
2026-06-12 11:03:19 +02:00

160 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
* ProviderReadGroupContextBuilder) — ERP-134.
*
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
*
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
* les sites SANS currentSite -> CurrentSiteProvider::get() = null -> aucun
* cloisonnement, on isole ainsi le comportement RBAC du comportement site.
*
* @internal
*/
final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
{
public function testAccountingFieldsOmittedWithoutAccountingView(): void
{
$provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789');
$id = $provider->getId();
// Profil type Commerciale : view + manage SANS accounting.view.
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// Gating par omission : scalaires comptables ET ribs totalement absents.
self::assertArrayNotHasKey('siren', $body);
self::assertArrayNotHasKey('ribs', $body);
// isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee).
self::assertArrayHasKey('isArchived', $body);
}
public function testAccountingFieldsPresentWithAccountingView(): void
{
$provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321');
$id = $provider->getId();
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame('987654321', $body['siren']);
// La cle ribs apparait (collection vide ici, mais presente).
self::assertArrayHasKey('ribs', $body);
}
public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void
{
$provider = $this->seedProvider('Strict Cible', [self::SITE_86]);
$id = $provider->getId();
// Profil type Bureau : manage SANS accounting.manage.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renomme', 'siren' => '111222333'],
]);
// RG-3.15 : payload melangeant main + accounting sans accounting.manage
// -> 403 sur tout le payload (mode strict, pas de filtrage silencieux).
self::assertSame(403, $response->getStatusCode());
// Aucun champ n'a ete persiste (rollback du mode strict).
$this->getEm()->clear();
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
self::assertSame('STRICT CIBLE', $reloaded->getCompanyName());
self::assertNull($reloaded->getSiren());
}
public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void
{
$provider = $this->seedProvider('Compta Editrice', [self::SITE_86]);
$id = $provider->getId();
// Profil type Compta : accounting.view + accounting.manage SANS manage.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// PATCH accounting -> 200.
$ok = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '555666777'],
]);
self::assertSame(200, $ok->getStatusCode());
// PATCH main (companyName) -> 403 (pas de permission manage).
$ko = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Interdit'],
]);
self::assertSame(403, $ko->getStatusCode());
}
public function testArchiveRequiresArchivePermission(): void
{
$provider = $this->seedProvider('A Archiver', [self::SITE_86]);
$id = $provider->getId();
// Bureau (manage) sans archive -> 403.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
// RG-3.13 : l'archivage exige technique.providers.archive.
self::assertSame(403, $response->getStatusCode());
}
public function testAdminCanArchiveAndSetsArchivedAt(): void
{
$provider = $this->seedProvider('Archivable', [self::SITE_86]);
$id = $provider->getId();
$client = $this->createAdminClient();
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertSame(200, $response->getStatusCode());
$this->getEm()->clear();
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt());
}
}