From 0e3299300f0c95d5888988098db2f814b3a7e603 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 1 Jun 2026 21:06:33 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-74]=20Seed=20RBAC=20idempotent=20(r=C3=B4l?= =?UTF-8?q?es=20+=20matrice=20=C2=A7=202.7=20+=20demo=20users)=20+=20RG-1.?= =?UTF-8?q?04=20+=20test=20matrice=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/40 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- CLAUDE.md | 6 +- README.md | 38 ++- docs/specs/M1-clients/spec-back.md | 3 +- makefile | 18 +- ...ClientInformationCompletenessValidator.php | 18 +- .../Commercial/Domain/Entity/Client.php | 12 +- .../State/Processor/ClientProcessor.php | 211 +++++++++++- .../Core/Application/Rbac/RbacSeeder.php | 218 +++++++++++++ .../Domain/Exception/RbacSeedException.php | 38 +++ .../Console/SeedRbacCommand.php | 138 ++++++++ .../DataFixtures/RbacDemoFixtures.php | 56 ++++ .../Commercial/Api/ClientRBACMatrixTest.php | 299 ++++++++++++++++++ .../Commercial/Unit/ClientProcessorTest.php | 105 +++++- 13 files changed, 1120 insertions(+), 40 deletions(-) create mode 100644 src/Module/Core/Application/Rbac/RbacSeeder.php create mode 100644 src/Module/Core/Domain/Exception/RbacSeedException.php create mode 100644 src/Module/Core/Infrastructure/Console/SeedRbacCommand.php create mode 100644 src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php create mode 100644 tests/Module/Commercial/Api/ClientRBACMatrixTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 1989820..59a41e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 6a93517..6663b9d 100644 --- a/README.md +++ b/README.md @@ -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='' +# 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 diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index cd7359a..3fa11b9 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -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 diff --git a/makefile b/makefile index 74857f9..6738ff5 100644 --- a/makefile +++ b/makefile @@ -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 diff --git a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php index 154184d..fe374fe 100644 --- a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php +++ b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php @@ -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. diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index 6240975..bf1d22e 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -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', diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index 5f665f1..f0c5c90 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -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 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 + */ + 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 $categories + * + * @return list + */ + 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 $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 + */ + private function extractPayloadKeys(string $content): array + { if ('' === $content) { return []; } diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php new file mode 100644 index 0000000..777b943 --- /dev/null +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -0,0 +1,218 @@ + 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}> + */ + 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 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 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 + */ + public static function roleCodes(): array + { + return array_keys(self::MATRIX); + } +} diff --git a/src/Module/Core/Domain/Exception/RbacSeedException.php b/src/Module/Core/Domain/Exception/RbacSeedException.php new file mode 100644 index 0000000..8544cd6 --- /dev/null +++ b/src/Module/Core/Domain/Exception/RbacSeedException.php @@ -0,0 +1,38 @@ +` 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; + } +} diff --git a/src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php new file mode 100644 index 0000000..31829ee --- /dev/null +++ b/src/Module/Core/Infrastructure/DataFixtures/RbacDemoFixtures.php @@ -0,0 +1,56 @@ + + */ + 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); + } +} diff --git a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php new file mode 100644 index 0000000..772c336 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php @@ -0,0 +1,299 @@ +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 + */ + 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], + ]; + } +} diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php index c9e848b..d6270d6 100644 --- a/tests/Module/Commercial/Unit/ClientProcessorTest.php +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -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.