feat(commercial) : enforce RG-1.04 completeness for commerciale role

- RG-1.04 durcie : pour une Commerciale, la completude de l'onglet Information est
  exigee sur POST et sur tout PATCH, independamment des champs envoyes (suppression
  de la condition d'intersection dans validateInformationCompleteness).
- Onglet Comptabilite editable par Compta : security du Patch /clients/{id} elargie
  a `manage` OU `accounting.manage` ; nouveau guardManage (ClientProcessor, mode
  strict RG-1.28) qui refuse a un porteur non-`manage` de modifier les onglets
  principal / Information -> 403. Compta reste donc cantonne a la Comptabilite.
- Spec § 7 RG-1.04 amendee (+ consequence POST 422) + docblock du validator.
- Tests unitaires ClientProcessor : guardManage (Compta accounting-only -> 200,
  champ metier -> 403) + RG-1.04 durcie hors onglet Information.
This commit is contained in:
Matthieu
2026-06-01 22:26:49 +02:00
parent 275c6ff5b5
commit 09e1d7884a
5 changed files with 226 additions and 31 deletions
@@ -134,7 +134,17 @@ final class ClientProcessorTest extends TestCase
'isArchived' => false,
],
managed: true,
originalData: ['isArchived' => false],
// Etat persiste complet (valeurs normalisees) : sans les champs
// metier, guardManage (ERP-74) les croirait modifies (companyName,
// lastName... compares a null) et leverait un 403 parasite.
originalData: [
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
@@ -153,8 +163,69 @@ final class ClientProcessorTest extends TestCase
payload: ['companyName' => 'Test Co', 'siren' => '123456789'],
managed: true,
// getOriginalEntityData renvoie tous les champs mappes d'une entite
// geree : isArchived (non-null) y figure toujours.
originalData: ['siren' => '123456789', 'isArchived' => false],
// geree : isArchived (non-null) y figure toujours, ainsi que les
// champs metier (sinon guardManage les croirait modifies).
originalData: [
'siren' => '123456789',
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testBusinessFieldWithoutManagePermissionIsForbidden(): void
{
// ERP-74 (guardManage) : modifier un champ metier (companyName) sur un
// client existant sans `manage` -> 403, meme avec accounting.manage
// (cas Compta qui sort de son onglet).
$client = $this->minimalClient();
$client->setCompanyName('Renamed Co');
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['companyName' => 'Renamed Co'],
managed: true,
originalData: [
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($client, $this->operation());
}
public function testAccountingOnlyPatchWithAccountingManageOnlyPasses(): void
{
// ERP-74 : Compta (accounting.manage, PAS manage) qui ne touche QUE
// l'onglet Comptabilite d'un client existant -> 200. guardManage ne
// declenche pas (aucun champ metier modifie), guardAccounting passe.
$client = $this->minimalClient();
$client->setSiren('999999999');
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['siren' => '999999999'],
managed: true,
originalData: [
'siren' => '111111111',
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
@@ -237,6 +308,34 @@ final class ClientProcessorTest extends TestCase
$processor->process($client, $this->operation());
}
public function testCommercialeIncompleteInformationOnNonInformationPatchIsUnprocessable(): void
{
// RG-1.04 durcie (ERP-74) : pour une Commerciale, la completude de
// l'onglet Information est exigee meme quand le payload ne touche PAS
// l'onglet Information (ici seulement companyName). L'ancienne condition
// d'intersection avec INFORMATION_FIELDS a ete retiree.
$client = $this->minimalClient();
$client->setCompanyName('Renamed Co'); // onglet principal uniquement, Information vide
$processor = $this->makeProcessor(
granted: ['commercial.clients.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.