[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

## 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:
2026-06-01 21:06:33 +00:00
committed by admin malio
parent 8d50f1fbe7
commit 0e3299300f
13 changed files with 1120 additions and 40 deletions
+4 -2
View File
@@ -3,7 +3,7 @@
## Contexte
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
Doc humaine : @README.md — Spec audit : @doc/audit-log.md
Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la demande, non chargés en permanence).
## Stack
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
@@ -37,7 +37,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
@.claude/rules/git.md
@.claude/rules/workflow.md
## Commandes (liste complete dans @README.md)
## Commandes (liste complete dans `README.md`)
- Demarrer : `make start`
- Dev front (hot reload) : `make dev-nuxt` (port 3004)
@@ -70,3 +70,5 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
## Credentials (dev)
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
Comptes demo des roles metier (seedes par `RbacDemoFixtures` / `app:seed-rbac --with-demo-users`, mot de passe `demo`) : `bureau` / `demo`, `compta` / `demo`, `commerciale` / `demo`, `usine` / `demo`. Matrice RBAC § 2.7 (M1 Clients) attachee aux roles correspondants.
+33 -5
View File
@@ -169,13 +169,41 @@ Secrets requis dans Gitea :
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
- `REGISTRY_TOKEN` — token pour le registry Docker
## Déploiement — seed RBAC (recette / prod)
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente** (présente dans le build prod,
contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions commercial.clients.*
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe
fourni explicitement, jamais en dur) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'env RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte).
En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo).
## Credentials (dev)
| Username | Password | Role |
|----------|----------|------|
| admin | admin | ROLE_ADMIN |
| alice | alice | ROLE_USER |
| bob | bob | ROLE_USER |
| Username | Password | Role | RBAC métier |
|----------|----------|------|-------------|
| admin | admin | ROLE_ADMIN | bypass (is_admin) |
| alice | alice | ROLE_USER | — |
| bob | bob | ROLE_USER | — |
| bureau | demo | ROLE_USER | clients : view + manage |
| compta | demo | ROLE_USER | clients : view + accounting.view/manage |
| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| usine | demo | ROLE_USER | aucun accès clients |
## Conventions
+2 -1
View File
@@ -885,7 +885,8 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Information
- **RG-1.04** : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) deviennent obligatoires lors d'un PATCH sur le groupe `client:write:information`. Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué par le `ClientProcessor` quand le user porte le rôle Commerciale.
- **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale.
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
### Onglet Contact
+16 -2
View File
@@ -198,8 +198,11 @@ migration-migrate:
# doctrine:fixtures:load essaie de DELETE toutes les tables connues
# via les mappings — si fake_site_aware_entity est mappe mais absent
# en DB, le purger crash.
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
# donc sync doit passer apres.
# 3. fixtures -> sync-permissions -> seed-rbac : fixtures:load purge la table
# permission, donc sync doit passer apres. seed-rbac (matrice RBAC § 2.7)
# passe ensuite, car attachMatrix() exige les permissions en base. Les
# comptes demo sont crees par RbacDemoFixtures au load (sans la matrice,
# attachee ici). Cf. ERP-74.
# 4. recreation des index partiels uniques : schema:update drop les index
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
@@ -220,6 +223,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
@@ -231,6 +235,15 @@ fixtures:
sync-permissions:
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
# Seed RBAC metier : roles (bureau/compta/commerciale/usine) + matrice § 2.7
# (+ comptes demo en dev). Idempotent et NON destructif. A lancer APRES
# sync-permissions (attachMatrix exige les permissions en base). Les comptes
# demo dev sont deja crees par RbacDemoFixtures (make fixtures) ; ici on attache
# la matrice (les permissions etaient purgees au moment du load fixtures).
# En recette/prod, c'est cette commande (avec/sans --with-demo-users) qui seede.
seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
# Attention, supprime votre bdd local
db-reset:
$(DOCKER_COMPOSE) down -v
@@ -240,6 +253,7 @@ db-reset:
$(MAKE) migration-migrate
$(MAKE) fixtures
$(MAKE) sync-permissions
$(MAKE) seed-rbac
$(MAKE) test-db-setup
# Restart la bdd
@@ -10,17 +10,15 @@ use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-1.04 : pour un utilisateur portant le role metier
* Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
* lors d'un PATCH touchant le groupe `client:write:information`.
* Validator metier RG-1.04 (durcie ERP-74) : pour un utilisateur portant le
* role metier Commerciale, TOUS les champs de l'onglet Information sont
* obligatoires sur POST comme sur tout PATCH, independamment des champs
* reellement envoyes.
*
* Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
* reunies (role Commerciale + payload touchant l'onglet Information). Pour les
* autres roles, ces champs restent optionnels — le validator n'est pas appele.
*
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
* DORMANTE : aucun appelant ne la declenche.
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role
* Commerciale (plus de condition d'intersection avec l'onglet Information).
* Pour les autres roles, ces champs restent optionnels — le validator n'est
* pas appele.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform.
+10 -2
View File
@@ -83,11 +83,19 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: ClientProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.manage')",
// Security elargie (ERP-74) : `manage` OU `accounting.manage`. Le
// role Compta n'a pas `manage` mais doit pouvoir editer l'onglet
// Comptabilite d'un client existant (§ 2.7). Le ClientProcessor
// re-gate ensuite onglet par onglet :
// - champs accounting -> accounting.manage (guardAccounting, RG-1.28) ;
// - champs main/information -> manage (guardManage : empeche Compta
// d'editer les autres onglets) ;
// - isArchived -> archive (guardArchive, RG-1.22).
security: "is_granted('commercial.clients.manage') or is_granted('commercial.clients.accounting.manage')",
// Le ClientProcessor inspecte les champs reellement envoyes pour
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
// champs accounting exigent accounting.manage, isArchived exige
// archive.
// archive, le reste (main/information) exige manage.
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => [
'client:write:main',
@@ -16,6 +16,7 @@ use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -31,16 +32,19 @@ use Symfony\Component\Validator\ConstraintViolationList;
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (le `security` de
* l'operation a deja exige commercial.clients.manage) :
* - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
* du PATCH a ete elargie (ERP-74) a `manage` OU `accounting.manage` pour
* laisser entrer le role Compta ; ce processor re-gate alors finement :
* - champ comptable modifie dans le payload -> exige accounting.manage (RG-1.28, 403) ;
* - champ main/information modifie -> exige manage (guardManage, 403) : empeche
* Compta d'editer un autre onglet que la Comptabilite (§ 2.7) ;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role
* Commerciale).
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* et tout PATCH pour le role Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
@@ -75,9 +79,23 @@ final class ClientProcessor implements ProcessorInterface
/** Champ d'archivage (groupe client:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_MANAGE = 'commercial.clients.manage';
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
private const string PERM_ARCHIVE = 'commercial.clients.archive';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
* payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys,
* categoriesChanged...) : on evite de rejouer json_decode a chaque appel. La
* cle etant le contenu lui-meme et le calcul une fonction pure de ce contenu,
* aucune fuite n'est possible entre requetes sur ce service partage (un meme
* corps redonne les memes cles).
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
@@ -101,10 +119,15 @@ final class ClientProcessor implements ProcessorInterface
$this->normalize($data);
// guardManage apres normalize : la comparaison « change vs etat
// persiste » des champs texte (companyName, email...) se fait sur des
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data, $writableKeys);
$this->validateInformationCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -199,6 +222,145 @@ final class ClientProcessor implements ProcessorInterface
}
}
/**
* § 2.7 / RG-1.28 (ERP-74) : la modification effective d'un champ « metier »
* (onglets principal ou Information) exige `commercial.clients.manage`. Sans
* cette permission -> 403 sur l'ensemble du payload (mode strict, miroir de
* guardAccounting). C'est ce qui empeche le role Compta — qui entre dans le
* PATCH via `accounting.manage` (security d'operation elargie) — d'editer
* autre chose que l'onglet Comptabilite.
*
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
* deja gardee par la security d'operation `manage`, donc inutile de la
* re-gater ici (et un POST par un porteur de `manage` passerait de toute
* facon).
*/
private function guardManage(Client $data): void
{
if (!$this->em->contains($data)) {
return;
}
$changed = $this->changedBusinessFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_MANAGE,
));
}
}
/**
* Champs « metier » (onglets principal + Information, hors comptabilite et
* archivage) dont la valeur courante differe de l'etat persiste. Memes
* regles de comparaison que changedAccountingFields (scalaires par valeur,
* relations ManyToOne distributor/broker par identite via l'identity map).
*
* Cas particulier `categories` (M2M) : non trace par getOriginalEntityData,
* compare par valeur via le snapshot de la PersistentCollection (cf.
* categoriesChanged) — la simple presence dans le payload ne suffit pas, sous
* peine de 403 parasite sur un PATCH representation complete reincluant des
* categories inchangees.
*
* @return list<string>
*/
private function changedBusinessFields(Client $data): array
{
$newValues = [
'companyName' => $data->getCompanyName(),
'firstName' => $data->getFirstName(),
'lastName' => $data->getLastName(),
'phonePrimary' => $data->getPhonePrimary(),
'phoneSecondary' => $data->getPhoneSecondary(),
'email' => $data->getEmail(),
'distributor' => $data->getDistributor(),
'broker' => $data->getBroker(),
'triageService' => $data->isTriageService(),
'description' => $data->getDescription(),
'competitors' => $data->getCompetitors(),
'foundedAt' => $data->getFoundedAt(),
'employeesCount' => $data->getEmployeesCount(),
'revenueAmount' => $data->getRevenueAmount(),
'directorName' => $data->getDirectorName(),
'profitAmount' => $data->getProfitAmount(),
];
$changed = [];
foreach ($newValues as $field => $newValue) {
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
if ($this->categoriesChanged($data)) {
$changed[] = 'categories';
}
return $changed;
}
/**
* Vrai si l'ensemble des categories (M2M) differe reellement de l'etat
* persiste. La collection n'etant pas tracee par getOriginalEntityData, on
* compare par identifiants (independamment de l'ordre) le snapshot de la
* PersistentCollection (etat charge depuis la base) a l'etat courant (apres
* application du payload). Symetrique de changedAccountingFields : seul un
* changement effectif compte, pas la simple presence dans le payload.
*
* - POST / entite non geree : fournir des categories est un acte metier
* (comportement historique conserve) — branche defensive, guardManage ne
* s'execute de toute facon que sur entite geree.
* - categories absent du payload (PATCH partiel) : aucun changement.
*/
private function categoriesChanged(Client $data): bool
{
if (!$this->em->contains($data)) {
return true;
}
if (!in_array('categories', $this->payloadKeys(), true)) {
return false;
}
$collection = $data->getCategories();
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute
// d'etat persiste comparable, on se rabat sur la presence payload.
if (!$collection instanceof PersistentCollection) {
return true;
}
return $this->categoryIdSet($collection->toArray())
!== $this->categoryIdSet($collection->getSnapshot());
}
/**
* Ensemble trie des identifiants d'une liste de categories — pour une
* comparaison par valeur independante de l'ordre.
*
* @param array<int, object> $categories
*
* @return list<mixed>
*/
private function categoryIdSet(array $categories): array
{
$ids = array_map(
static fn (object $category): mixed => method_exists($category, 'getId')
? $category->getId()
: spl_object_id($category),
array_values($categories),
);
sort($ids);
return $ids;
}
/**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
@@ -353,17 +515,16 @@ final class ClientProcessor implements ProcessorInterface
}
/**
* RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
* payload touche l'onglet Information, tous les champs Information sont
* obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
*
* @param list<string> $payloadKeys
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
* POST comme sur TOUT PATCH — independamment des champs reellement envoyes
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
* client cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet.
*/
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
private function validateInformationCompleteness(Client $data): void
{
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
if ($touchesInformation && $this->currentUserIsCommerciale()) {
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
@@ -428,6 +589,26 @@ final class ClientProcessor implements ProcessorInterface
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\Rbac;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\RbacSeedException;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Source UNIQUE (anti-drift) du RBAC metier MALIO : les 4 roles
* (bureau / compta / commerciale / usine), la matrice § 2.7 (role -> permissions)
* et les comptes demo par role. Aucun de ces litteraux ne doit etre duplique
* ailleurs (ni SQL en dur, ni autre fixture).
*
* Consomme par :
* - la commande applicative `app:seed-rbac` (presente dans le build prod, donc
* rejouable en recette/prod, contrairement aux fixtures `require-dev`) ;
* - la fixture Core dev/test (DRY : meme seeder).
*
* Toutes les operations sont idempotentes et non destructives :
* - ensureRoles() : cree un role par lookup de code (skip si present) ;
* - attachMatrix() : attache les permissions § 2.7 via la M2M role_permission,
* sans re-attacher un lien existant ; STOP explicite si un code manque ;
* - ensureDemoUsers() : cree un user par role (lookup par username, skip si
* present), rattache au role + a >= 1 site.
*/
final class RbacSeeder
{
/**
* Codes des roles metier (snake_case, regex Role respectee). `commerciale`
* reference la constante Shared deja consommee par le ClientProcessor
* (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
*/
public const string ROLE_BUREAU = 'bureau';
public const string ROLE_COMPTA = 'compta';
public const string ROLE_COMMERCIALE = BusinessRoles::COMMERCIALE;
public const string ROLE_USINE = 'usine';
/** Site de rattachement par defaut des comptes demo (cf. SitesFixtures). */
private const string DEFAULT_SITE_NAME = 'Chatellerault';
/**
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
* bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a
* aucun role metier — admin seul).
*
* @var array<string, array{label: string, permissions: list<string>}>
*/
private const array MATRIX = [
self::ROLE_BUREAU => [
'label' => 'Bureau',
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
],
],
self::ROLE_COMPTA => [
'label' => 'Comptabilité',
'permissions' => [
'commercial.clients.view',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
],
],
self::ROLE_COMMERCIALE => [
'label' => 'Commerciale',
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
],
],
self::ROLE_USINE => [
'label' => 'Usine',
'permissions' => [],
],
];
public function __construct(
private readonly RoleRepositoryInterface $roleRepository,
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly SiteProviderInterface $siteProvider,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
/**
* Cree chaque role metier absent (lookup par code). Idempotent.
*
* @return list<string> codes des roles effectivement crees (vide au rejeu)
*/
public function ensureRoles(): array
{
$created = [];
foreach (self::MATRIX as $code => $definition) {
if (null !== $this->roleRepository->findByCode($code)) {
continue;
}
// isSystem=false : ce sont des roles metier, supprimables par un
// admin (contrairement aux roles systeme admin/user).
$this->roleRepository->save(new Role($code, $definition['label'], isSystem: false));
$created[] = $code;
}
return $created;
}
/**
* Attache la matrice § 2.7 a chaque role via la M2M role_permission. Lookup
* de la permission par code ; un code absent leve une RbacSeedException
* (garde-fou : `app:sync-permissions` doit avoir tourne). Idempotent : un
* lien deja present n'est pas recree.
*
* @return int nombre de liens role->permission effectivement ajoutes (0 au rejeu)
*
* @throws RbacSeedException si un role ou une permission de la matrice manque
*/
public function attachMatrix(): int
{
$added = 0;
foreach (self::MATRIX as $code => $definition) {
$role = $this->roleRepository->findByCode($code);
if (null === $role) {
throw RbacSeedException::missingRole($code);
}
$touched = false;
foreach ($definition['permissions'] as $permissionCode) {
$permission = $this->permissionRepository->findByCode($permissionCode);
if (null === $permission) {
throw RbacSeedException::missingPermission($permissionCode);
}
if (!$role->getPermissions()->contains($permission)) {
$role->addPermission($permission);
$touched = true;
++$added;
}
}
// Un seul flush par role, et seulement si un lien a change.
if ($touched) {
$this->roleRepository->save($role);
}
}
return $added;
}
/**
* Cree un compte demo par role metier (username = code du role), non-admin,
* mot de passe hashe, rattache a son role et a >= 1 site. Lookup par
* username : idempotent (un compte existant est laisse intact, mot de passe
* inchange).
*
* @return list<string> usernames effectivement crees (vide au rejeu)
*
* @throws RbacSeedException si un role metier attendu est absent (ensureRoles non joue)
*/
public function ensureDemoUsers(string $password): array
{
// Rattachement a un site par defaut s'il existe (les flux login / me en
// ont besoin ; le repertoire clients n'est pas site-scope mais on reste
// coherent avec les fixtures admin/alice/bob).
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
$created = [];
foreach (array_keys(self::MATRIX) as $code) {
$username = $code;
if (null !== $this->userRepository->findByUsername($username)) {
continue;
}
$role = $this->roleRepository->findByCode($code);
if (null === $role) {
throw RbacSeedException::missingRole($code);
}
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($this->passwordHasher->hashPassword($user, $password));
$user->addRbacRole($role);
if (null !== $defaultSite) {
$user->addSite($defaultSite);
$user->setCurrentSite($defaultSite);
}
$this->userRepository->save($user);
$created[] = $username;
}
return $created;
}
/**
* Liste des codes des roles metier definis (pour reporting / tests).
*
* @return list<string>
*/
public static function roleCodes(): array
{
return array_keys(self::MATRIX);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use RuntimeException;
/**
* Erreur de seed RBAC (service RbacSeeder / commande app:seed-rbac).
*
* Deux causes possibles, toutes deux fatales et explicites :
* - role metier attendu introuvable (ensureRoles() n'a pas tourne avant
* attachMatrix() ou ensureDemoUsers()) ;
* - code de permission de la matrice § 2.7 absent du catalogue : signe que
* `app:sync-permissions` n'a pas ete joue. Le message embarque alors
* l'invite a lancer la synchronisation, exploitee telle quelle par la
* commande.
*/
final class RbacSeedException extends RuntimeException
{
public static function missingRole(string $roleCode): self
{
return new self(sprintf(
'Role metier "%s" introuvable. Appelle RbacSeeder::ensureRoles() avant attachMatrix()/ensureDemoUsers().',
$roleCode,
));
}
public static function missingPermission(string $permissionCode): self
{
return new self(sprintf(
'Permission "%s" (matrice § 2.7) absente du catalogue. '
.'Lance d\'abord `bin/console app:sync-permissions` pour la poser en base, puis relance le seed RBAC.',
$permissionCode,
));
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Exception\RbacSeedException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Seed RBAC metier idempotent et NON destructif (cf. ERP-74 / spec-back M1
* § 2.7). Contrairement aux fixtures Doctrine (`require-dev`, absentes du build
* prod `--no-dev`), cette commande applicative est presente dans l'image prod :
* elle est donc rejouable en recette/staging/prod.
*
* Etape de release : a lancer APRES `doctrine:migrations:migrate` et
* `app:sync-permissions`.
* - En prod : `app:seed-rbac` (roles + matrice § 2.7, sans comptes demo).
* - En recette : `app:seed-rbac --with-demo-users --password=<...>` pour
* disposer de logins de test.
*
* Toute la logique (litteraux des roles, matrice, comptes demo) vit dans
* RbacSeeder — cette commande n'en est que l'enveloppe CLI.
*/
#[AsCommand(
name: 'app:seed-rbac',
description: 'Seede les roles metier RBAC + la matrice § 2.7 (idempotent, non destructif).',
)]
final class SeedRbacCommand extends Command
{
/** Variable d'environnement de repli pour le mot de passe des comptes demo. */
private const string PASSWORD_ENV = 'RBAC_DEMO_PASSWORD';
public function __construct(private readonly RbacSeeder $seeder)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'with-demo-users',
null,
InputOption::VALUE_NONE,
'Cree aussi un compte demo par role metier (recette/dev — JAMAIS en prod).',
)
->addOption(
'password',
null,
InputOption::VALUE_REQUIRED,
'Mot de passe des comptes demo (defaut : variable d\'env '.self::PASSWORD_ENV.'). Requis avec --with-demo-users.',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// 1. Roles metier + matrice § 2.7. attachMatrix() exige que les
// permissions soient en base : sinon RbacSeedException porteuse de
// l'invite a lancer `app:sync-permissions`.
try {
$createdRoles = $this->seeder->ensureRoles();
$addedLinks = $this->seeder->attachMatrix();
} catch (RbacSeedException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
$io->text(sprintf(
'Roles metier : %d cree(s), matrice § 2.7 : %d lien(s) ajoute(s).',
count($createdRoles),
$addedLinks,
));
// 2. Comptes demo (optionnel, jamais en prod).
if ((bool) $input->getOption('with-demo-users')) {
$password = $this->resolveDemoPassword($input);
if (null === $password) {
$io->error(sprintf(
'--with-demo-users exige un mot de passe : passe --password=<...> ou definis la variable d\'env %s. '
.'(Aucun mot de passe en dur cote serveur.)',
self::PASSWORD_ENV,
));
return Command::FAILURE;
}
try {
$createdUsers = $this->seeder->ensureDemoUsers($password);
} catch (RbacSeedException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
$io->text(sprintf(
'Comptes demo : %d cree(s)%s.',
count($createdUsers),
[] === $createdUsers ? '' : ' ['.implode(', ', $createdUsers).']',
));
}
$io->success('Seed RBAC metier termine (idempotent).');
return Command::SUCCESS;
}
/**
* Resout le mot de passe demo : option `--password` prioritaire, sinon
* variable d'environnement. Renvoie null si aucun n'est fourni (la commande
* refuse alors --with-demo-users plutot que d'inventer un mot de passe).
*/
private function resolveDemoPassword(InputInterface $input): ?string
{
/** @var null|string $option */
$option = $input->getOption('password');
if (null !== $option && '' !== $option) {
return $option;
}
$env = $_SERVER[self::PASSWORD_ENV] ?? $_ENV[self::PASSWORD_ENV] ?? getenv(self::PASSWORD_ENV);
if (is_string($env) && '' !== $env) {
return $env;
}
return null;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\DataFixtures;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
/**
* Fixture dev/test des roles metier MALIO (bureau / compta / commerciale /
* usine) + comptes demo associes. DRY : delegue au MEME service RbacSeeder que
* la commande `app:seed-rbac`, de sorte que `make db-reset` reproduise l'etat
* de recette.
*
* Depend de SitesFixtures : les comptes demo sont rattaches au site par defaut
* (cf. RbacSeeder::DEFAULT_SITE_NAME).
*
* ⚠ N'attache PAS la matrice § 2.7 ici : `doctrine:fixtures:load` PURGE la table
* `permission` avant de charger, donc les codes `commercial.clients.*` ne sont
* pas encore en base au moment du load (cf. ordre du makefile : fixtures PUIS
* `app:sync-permissions`). La matrice est attachee juste apres, par l'etape
* `app:seed-rbac` du makefile (db-reset / test-db-setup), via le meme seeder.
* Resultat final identique a la recette : roles + matrice + comptes demo.
*/
final class RbacDemoFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Mot de passe DEV/TEST connu des comptes demo (bureau / compta /
* commerciale / usine). Reference par les tests fonctionnels de matrice
* RBAC. Sans rapport avec la prod : en recette/prod le mot de passe est
* fourni explicitement a `app:seed-rbac --with-demo-users --password=...`.
*/
public const string DEMO_PASSWORD = 'demo';
public function __construct(private readonly RbacSeeder $seeder) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
// Idempotent : ensureRoles puis ensureDemoUsers (lookup par code /
// username). La matrice est volontairement deferree (cf. docblock).
$this->seeder->ensureRoles();
$this->seeder->ensureDemoUsers(self::DEMO_PASSWORD);
}
}
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire clients par role metier (spec-back M1
* § 2.7 + cahier ERP-74). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, plus le durcissement RG-1.04
* (Commerciale) au POST.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente.
* Pre-requis du run : `app:sync-permissions` a tourne (cf. make test-db-setup).
*
* @internal
*/
final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
private const string MERGE = 'application/merge-patch+json';
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.7 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions commercial.clients.* sont-elles synchronisees (app:sync-permissions) ?',
);
// Liberer le kernel pour que authenticatedClient()/createClient() reparte propre.
self::ensureKernelShutdown();
}
public function testUsineIsForbiddenEverywhere(): void
{
$seed = $this->seedClient('Usine Target');
$client = $this->authAs('usine');
// Aucune permission : 403 sur tous les verbes.
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renamed By Usine'],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedClient('Bureau Target');
$cat = $this->createCategory('SECTEUR');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition onglet principal OK
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Bureau Renamed'],
]);
self::assertResponseStatusCodeSame(200);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaCanEditAccountingOnly(): void
{
$seed = $this->seedClient('Compta Target');
$client = $this->authAs('compta');
// view
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
// accounting.manage : edition onglet Comptabilite OK
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(200);
// PAS manage : edition onglet principal refusee (guardManage)
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Compta Renamed'],
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition onglet Information refusee (guardManage)
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['description' => 'Une description'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedClient('Commerciale Target');
$client = $this->authAs('commerciale');
// view
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : la creation passe la security d'operation (pas un 403 comme
// Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422.
// C'est la preuve que Commerciale porte `manage` (sinon 403).
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'),
]);
self::assertResponseStatusCodeSame(422);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testRG104CommercialePostIncompleteIs422AdminIs201(): void
{
$cat = $this->createCategory('SECTEUR');
// RG-1.04 durcie : Commerciale POST sans onglet Information complet -> 422.
$commerciale = $this->authAs('commerciale');
$commerciale->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG104 Commerciale', $cat->getId()),
]);
self::assertResponseStatusCodeSame(422);
// Meme payload par un Admin (non gate par RG-1.04) -> 201.
$admin = $this->createAdminClient();
$admin->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG104 Admin', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
}
public function testComptaFullRepresentationPatchWithUnchangedCategoriesIsNotForbidden(): void
{
// FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un
// PATCH representation complete de l'onglet Comptabilite et reincluant ses
// categories INCHANGEES ne doit PAS prendre de 403. guardManage compare
// desormais les categories par valeur (et non par simple presence) : seul
// l'onglet Comptabilite change ici -> 200.
$seed = $this->seedClient('Compta Cat Unchanged');
$category = $seed->getCategories()->first();
self::assertNotFalse($category);
$catId = $category->getId();
$client = $this->authAs('compta');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'siren' => '123456789',
'categories' => ['/api/categories/'.$catId],
],
]);
self::assertResponseStatusCodeSame(200);
}
public function testComptaChangingCategoriesIsForbidden(): void
{
// Non-regression : si le Compta change REELLEMENT l'ensemble des
// categories (sans manage) -> 403 via guardManage. La comparaison par
// valeur detecte bien le changement.
$seed = $this->seedClient('Compta Cat Change');
$newCat = $this->createCategory('SECTEUR');
$client = $this->authAs('compta');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['categories' => ['/api/categories/'.$newCat->getId()]],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauChangingCategoriesIsAllowed(): void
{
// Non-regression : un role porteur de `manage` (Bureau) peut changer les
// categories -> 200.
$seed = $this->seedClient('Bureau Cat Change');
$newCat = $this->createCategory('SECTEUR');
$client = $this->authAs('bureau');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['categories' => ['/api/categories/'.$newCat->getId()]],
]);
self::assertResponseStatusCodeSame(200);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
/**
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ;
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a
* la volee.
*
* @return array<string, mixed>
*/
private function validMainPayload(string $companyName, ?int $categoryId = null): array
{
$categoryId ??= $this->createCategory('SECTEUR')->getId();
return [
'companyName' => $companyName,
'firstName' => 'Jean',
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'categories' => ['/api/categories/'.$categoryId],
];
}
}
@@ -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.