[ERP-74] Seed RBAC idempotent (rôles + matrice § 2.7 + demo users) + RG-1.04 + test matrice (#40)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
## Objectif
Seeder le RBAC métier de façon **rejouable et disponible en recette/prod** (commande applicative, pas fixture `require-dev`), durcir RG-1.04, et écrire le test de matrice (rôles enfin existants).
## A. `RbacSeeder` (Core) — source unique anti-drift
4 rôles (`bureau`/`compta`/`commerciale`/`usine`, isSystem=false), matrice § 2.7 (rôle → permissions) et comptes démo, définis en **un seul endroit**. Méthodes idempotentes `ensureRoles` / `attachMatrix` / `ensureDemoUsers`. `commerciale` référence `BusinessRoles::COMMERCIALE` (déjà consommé par RG-1.04).
## B. Commande `app:seed-rbac` (présente en build prod, idempotente, non destructive)
- Sans option : rôles + matrice § 2.7.
- `--with-demo-users` + `--password=<…>` ou env `RBAC_DEMO_PASSWORD` : 1 compte démo/rôle. **Aucun mot de passe en dur** côté serveur.
- Garde-fou : exit non-zéro + invite à lancer `app:sync-permissions` si les codes `commercial.clients.*` manquent.
## C. Fixture dev/test `RbacDemoFixtures` (DRY)
Appelle le **même seeder** (`ensureRoles` + `ensureDemoUsers`). La matrice est attachée juste après par l'étape `app:seed-rbac` du makefile (la table `permission` est purgée au moment du `fixtures:load`, donc `attachMatrix` ne peut pas tourner pendant le load). `make db-reset` / `test-db-setup` reproduisent l'état de recette.
## Déploiement (documenté README)
Après `migration-migrate` + `app:sync-permissions` : `app:seed-rbac` (prod) ; `app:seed-rbac --with-demo-users --password=…` (recette).
## D. Durcissement RG-1.04
Pour une Commerciale, complétude de l'onglet Information exigée sur **POST + tout PATCH** (suppression de la condition d'intersection). Conséquence : POST Commerciale → 422 (le POST n'expose pas le groupe Information), Admin → 201. Spec § 7 amendée.
## Compta ↔ onglet Comptabilité (§ 2.7)
Pour que `compta PATCH accounting → 200` (exigé par la matrice), la security du `Patch /clients/{id}` est élargie à `manage` **OU** `accounting.manage`, et un nouveau **`guardManage`** (mode strict RG-1.28) interdit à un porteur non-`manage` de modifier les onglets principal/Information (→ 403). Approche validée : élargir la security + guard in-processor (pas de nouvel endpoint).
## E. `ClientRBACMatrixTest`
Matrice § 2.7 complète via les comptes démo seedés (`app:seed-rbac --with-demo-users`) : bureau / compta / commerciale / usine (200/403 par verbe et par onglet) + RG-1.04 (POST Commerciale 422 / Admin 201).
## Tests
`make php-cs-fixer-allow-risky` OK ; `make test` **429 tests verts**. Idempotence vérifiée (rejeu de la commande : 0 rôle / 0 lien / 0 user). `test-db-setup` exécute la nouvelle étape `app:seed-rbac` sans erreur.
Cible : `develop`. Squash merge.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #40
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #40.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user