Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38f9f164f1 | |||
| c21bfea7f6 | |||
| f29587f113 | |||
| aa1a42e659 | |||
| a84bde5bcc | |||
| 0f8fc48df0 | |||
| d3d00425f7 | |||
| a9998d4bcd | |||
| 034301ceaf | |||
| 8d0a9a67ef | |||
| bc4b1d0492 |
@@ -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` (à lire à la demande, non chargés en permanence).
|
||||
Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
|
||||
## 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` (à lire à la dem
|
||||
@.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,5 +70,3 @@ 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.
|
||||
|
||||
@@ -169,41 +169,13 @@ 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 | 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 |
|
||||
| Username | Password | Role |
|
||||
|----------|----------|------|
|
||||
| admin | admin | ROLE_ADMIN |
|
||||
| alice | alice | ROLE_USER |
|
||||
| bob | bob | ROLE_USER |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.60'
|
||||
app.version: '0.1.58'
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478)
|
||||
|
||||
Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60.
|
||||
|
||||
Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons
|
||||
précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les
|
||||
trous, zéro duplication »).
|
||||
|
||||
## Stratégie
|
||||
|
||||
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
|
||||
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
|
||||
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
|
||||
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
|
||||
merge de la stack.
|
||||
|
||||
## Mapping RG → test
|
||||
|
||||
| RG | Intitulé | Test(s) | Source |
|
||||
|----|----------|---------|--------|
|
||||
| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||
| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** |
|
||||
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
|
||||
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
|
||||
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** |
|
||||
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
|
||||
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
|
||||
| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** |
|
||||
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
|
||||
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
|
||||
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
|
||||
| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
||||
| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** |
|
||||
| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
||||
| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 |
|
||||
| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 |
|
||||
| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** |
|
||||
| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 |
|
||||
| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 |
|
||||
| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** |
|
||||
| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||
| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 |
|
||||
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
|
||||
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
|
||||
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) |
|
||||
|
||||
## Couvertures transverses
|
||||
|
||||
| Sujet | Test(s) | Source |
|
||||
|-------|---------|--------|
|
||||
| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** |
|
||||
| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 |
|
||||
| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** |
|
||||
| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 |
|
||||
| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 |
|
||||
|
||||
## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60
|
||||
|
||||
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
|
||||
Usine) : 200/403 par verbe et par onglet selon le rôle.
|
||||
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
|
||||
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
|
||||
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
|
||||
|
||||
## Gaps & suivi
|
||||
|
||||
- **RG-1.29 (validation écriture)** : refuser une catégorie de type
|
||||
`DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation
|
||||
`categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme
|
||||
cas de test back ; le filtrage de lecture est front-driven. **Suggestion** :
|
||||
ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à
|
||||
ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only).
|
||||
- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11)
|
||||
sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin
|
||||
vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests
|
||||
ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422
|
||||
serait une amélioration UX d'API (follow-up possible).
|
||||
@@ -885,8 +885,7 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### Onglet Information
|
||||
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
### Onglet Contact
|
||||
|
||||
|
||||
@@ -198,11 +198,8 @@ 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 -> 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.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
# 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
|
||||
@@ -223,7 +220,6 @@ 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"
|
||||
|
||||
@@ -235,15 +231,6 @@ 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
|
||||
@@ -253,7 +240,6 @@ db-reset:
|
||||
$(MAKE) migration-migrate
|
||||
$(MAKE) fixtures
|
||||
$(MAKE) sync-permissions
|
||||
$(MAKE) seed-rbac
|
||||
$(MAKE) test-db-setup
|
||||
|
||||
# Restart la bdd
|
||||
|
||||
+10
-8
@@ -10,15 +10,17 @@ use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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`.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||
* coherence avec les violations Symfony rendues par API Platform.
|
||||
|
||||
@@ -83,19 +83,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
// 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')",
|
||||
security: "is_granted('commercial.clients.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, le reste (main/information) exige manage.
|
||||
// archive.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'client:write:main',
|
||||
|
||||
+15
-104
@@ -31,19 +31,16 @@ 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. 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) ;
|
||||
* 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) ;
|
||||
* - 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 exigee sur POST
|
||||
* et tout PATCH pour le role Commerciale).
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information 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
|
||||
@@ -78,7 +75,6 @@ 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';
|
||||
|
||||
@@ -105,15 +101,10 @@ 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);
|
||||
$this->validateInformationCompleteness($data, $writableKeys);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
@@ -208,87 +199,6 @@ 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,
|
||||
* on se rabat sur sa presence explicite dans le payload (modifier les
|
||||
* categories = action metier exigeant manage).
|
||||
*
|
||||
* @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 (in_array('categories', $this->writablePayloadKeys(), true)) {
|
||||
$changed[] = 'categories';
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
@@ -443,16 +353,17 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
private function validateInformationCompleteness(Client $data): void
|
||||
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
|
||||
{
|
||||
if ($this->currentUserIsCommerciale()) {
|
||||
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
|
||||
|
||||
if ($touchesInformation && $this->currentUserIsCommerciale()) {
|
||||
$this->informationValidator->validate($data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'onglet Adresse — combler les trous (ERP-60).
|
||||
*
|
||||
* RG-1.09 (code postal) et RG-1.10 (>= 1 site) sont DEJA couverts par
|
||||
* ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier
|
||||
* cible les contraintes CHECK BDD non encore testees :
|
||||
* - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive`
|
||||
* (is_prospect exclusif de is_delivery / is_billing) ;
|
||||
* - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire
|
||||
* ssi is_billing).
|
||||
*
|
||||
* Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de
|
||||
* regle Processor au M1). On verifie donc que la combinaison invalide est
|
||||
* REJETEE par le serveur (statut >= 400), sans coupler le test au code exact :
|
||||
* une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un
|
||||
* mapping fin vers 422 serait une amelioration ulterieure (hors perimetre
|
||||
* ERP-60, test-only).
|
||||
*
|
||||
* RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est
|
||||
* PAS testee : la validation d'ecriture correspondante n'est pas implementee
|
||||
* cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap
|
||||
* dans le cahier de test #478.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une
|
||||
* adresse de livraison (CHECK chk_client_address_prospect_exclusive).
|
||||
*/
|
||||
public function testProspectAddressCannotBeDelivery(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Prospect Delivery');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isProspect' => true,
|
||||
'isDelivery' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une
|
||||
* adresse de facturation (meme CHECK). On fournit billingEmail pour que la
|
||||
* seule violation possible soit l'exclusivite prospect/billing.
|
||||
*/
|
||||
public function testProspectAddressCannotBeBilling(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Prospect Billing');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isProspect' => true,
|
||||
'isBilling' => true,
|
||||
'billingEmail' => 'facturation@test.fr',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : une adresse de facturation exige un billingEmail
|
||||
* (CHECK chk_client_address_billing_email).
|
||||
*/
|
||||
public function testBillingAddressRequiresBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Billing No Email');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un
|
||||
* billingEmail (meme CHECK).
|
||||
*/
|
||||
public function testNonBillingAddressRejectsBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Non Billing With Email');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => false,
|
||||
'billingEmail' => 'parasite@test.fr',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'IRI du premier site seede (fixtures Sites).
|
||||
*/
|
||||
private function firstSiteIri(): string
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
||||
|
||||
return '/api/sites/'.$site->getId();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests d'archivage / restauration — combler les trous (ERP-60).
|
||||
*
|
||||
* Le cas nominal RG-1.22 (archive pose archivedAt) + RG-1.23 (restauration
|
||||
* repasse archivedAt a null) ainsi que le 422 « archive + autre champ » sont
|
||||
* DEJA couverts par ClientApiTest (ERP-55). Ce fichier cible le trou identifie
|
||||
* en revue (P1 review ERP-55) : le 409 de RESTAURATION en conflit d'unicite.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientArchiveTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/**
|
||||
* RG-1.23 : restaurer un client archive dont le nom a ete repris par un
|
||||
* client actif entre-temps doit echouer en 409 (l'index partiel
|
||||
* uq_client_company_name_active n'admet qu'un seul actif portant ce nom).
|
||||
*
|
||||
* Scenario :
|
||||
* 1. un client « ACME CONFLICT » est archive (donc hors index partiel) ;
|
||||
* 2. un autre client actif « ACME CONFLICT » est cree (autorise tant que le
|
||||
* premier reste archive) ;
|
||||
* 3. la restauration du premier le rendrait actif -> collision d'unicite
|
||||
* -> ClientProcessor traduit la UniqueConstraintViolationException en 409.
|
||||
*/
|
||||
public function testRestoreConflictReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$archived = $this->seedClient('Acme Conflict', true);
|
||||
$this->seedClient('Acme Conflict', false);
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit + Timestampable/Blamable du repertoire clients (ERP-60).
|
||||
*
|
||||
* Couvre :
|
||||
* - RG-1.27 : createdAt / createdBy figes au POST, updatedBy reflete bien
|
||||
* l'auteur de la modification (POST admin puis PATCH par un autre user) ;
|
||||
* - Audit (§ 6.1) : le RIB est `#[Auditable]` SANS `#[AuditIgnore]` sur iban /
|
||||
* bic — ces champs sensibles DOIVENT donc apparaitre dans le diff audite
|
||||
* (decision Matthieu, revue MR 29/05/2026).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientAuditTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
private const string RIB_TYPE = 'commercial.ClientRib';
|
||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
|
||||
private ?Connection $auditConnection = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->auditConnection) {
|
||||
$this->auditConnection->close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.27 : createdAt / createdBy sont poses au POST puis figes ; updatedBy
|
||||
* suit l'auteur de la derniere modification. On cree en admin puis on
|
||||
* modifie avec un user `commercial.clients.manage` distinct : createdBy reste
|
||||
* l'admin, updatedBy devient le manager, createdAt ne bouge pas.
|
||||
*/
|
||||
public function testCreatedFrozenAndUpdatedByReflectsModifier(): void
|
||||
{
|
||||
// 1. User modificateur (non-admin) cree AVANT le reboot de kernel induit
|
||||
// par les clients authentifies suivants ; il est persiste en base.
|
||||
$manageCreds = $this->createUserWithPermission('commercial.clients.manage');
|
||||
|
||||
// 2. Creation en admin (createdBy = admin).
|
||||
$admin = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$created = $admin->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Blamable Co',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'blamable@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$id = (int) $created['id'];
|
||||
$createdAtTs = new DateTimeImmutable((string) $created['createdAt'])->getTimestamp();
|
||||
|
||||
// 3. Modification par le manager (updatedBy = manager).
|
||||
$manage = $this->authenticatedClient($manageCreds['username'], $manageCreds['password']);
|
||||
$manage->request('PATCH', '/api/clients/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Blamable Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// 4. Verification cote base (etat re-charge depuis la BDD).
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(ClientEntity::class)->find($id);
|
||||
self::assertNotNull($reloaded);
|
||||
|
||||
self::assertSame('admin', $reloaded->getCreatedBy()?->getUserIdentifier(), 'createdBy doit rester l\'admin createur.');
|
||||
self::assertSame(
|
||||
$manageCreds['username'],
|
||||
$reloaded->getUpdatedBy()?->getUserIdentifier(),
|
||||
'updatedBy doit refleter le dernier modificateur.',
|
||||
);
|
||||
self::assertSame($createdAtTs, $reloaded->getCreatedAt()?->getTimestamp(), 'createdAt doit etre fige au POST.');
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
self::assertGreaterThanOrEqual($createdAtTs, $reloaded->getUpdatedAt()->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit § 6.1 : la creation d'un RIB produit une ligne audit_log
|
||||
* `commercial.ClientRib` / `create` dont le snapshot contient iban et bic
|
||||
* (champs volontairement NON ignores).
|
||||
*/
|
||||
public function testRibCreateAuditIncludesIbanAndBic(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Audit Host');
|
||||
|
||||
$rib = $admin->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte audite',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$rows = $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT changes FROM audit_log '
|
||||
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
|
||||
.'ORDER BY performed_at DESC',
|
||||
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
|
||||
);
|
||||
|
||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
|
||||
|
||||
/** @var array<string, mixed> $changes */
|
||||
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
|
||||
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
|
||||
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
|
||||
self::assertSame(self::VALID_IBAN, $changes['iban']);
|
||||
self::assertSame(self::VALID_BIC, $changes['bic']);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du formulaire principal — combler les trous (ERP-60).
|
||||
*
|
||||
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
|
||||
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
|
||||
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
|
||||
* non encore testee.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
|
||||
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
|
||||
* la colonne secondaire.
|
||||
*/
|
||||
public function testPostPersistsSecondaryPhoneNormalized(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$data = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Two Phones SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'phoneSecondary' => '05 49 00 11 22',
|
||||
'email' => 'twophones@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('0549001122', $data['phoneSecondary']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et
|
||||
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
|
||||
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
|
||||
*/
|
||||
public function testThirdPhoneFieldIsIgnored(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$data = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Third Phone SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0612345678',
|
||||
'phoneSecondary' => '0549001122',
|
||||
'phoneTertiary' => '0700000000',
|
||||
'email' => 'thirdphone@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
|
||||
// dans la representation et n'a pas ete persiste.
|
||||
self::assertArrayNotHasKey('phoneTertiary', $data);
|
||||
|
||||
// Confirmation cote base : seules les 2 colonnes telephone existent.
|
||||
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
|
||||
self::assertNotNull($persisted);
|
||||
self::assertSame('0612345678', $persisted->getPhonePrimary());
|
||||
self::assertSame('0549001122', $persisted->getPhoneSecondary());
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests de structure / migration M1 (ERP-60).
|
||||
*
|
||||
* Verifie la decision Q4 (29/05/2026) au niveau du schema Postgres :
|
||||
* - l'unique index partiel fonctionnel uq_client_company_name_active existe
|
||||
* (un seul, sur LOWER(company_name), partiel sur les actifs non archives /
|
||||
* non supprimes) — seule unicite metier conservee (RG-1.16) ;
|
||||
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
|
||||
* (RG-1.17) ont ete supprimes / ne sont jamais crees.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientMigrationTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
|
||||
{
|
||||
$rows = $this->clientIndexes();
|
||||
|
||||
$companyNameIndexes = array_filter(
|
||||
$rows,
|
||||
static fn (array $r): bool => 'uq_client_company_name_active' === $r['indexname'],
|
||||
);
|
||||
|
||||
self::assertCount(
|
||||
1,
|
||||
$companyNameIndexes,
|
||||
'Il doit exister exactement UN index uq_client_company_name_active.',
|
||||
);
|
||||
|
||||
// Confirme la nature fonctionnelle (LOWER) + partielle (WHERE) de l'index.
|
||||
// Postgres serialise l'expression sous la forme `lower((company_name)::text)`,
|
||||
// d'ou des verifications de sous-chaines distinctes.
|
||||
$def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
|
||||
self::assertStringContainsString('unique', $def);
|
||||
self::assertStringContainsString('lower', $def);
|
||||
self::assertStringContainsString('company_name', $def);
|
||||
self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
|
||||
}
|
||||
|
||||
public function testNoSirenOrEmailUniqueIndex(): void
|
||||
{
|
||||
$names = array_map(static fn (array $r): string => $r['indexname'], $this->clientIndexes());
|
||||
|
||||
// RG-1.15 / RG-1.17 supprimees (Q4) : aucun index unique siren / email.
|
||||
self::assertNotContains('uq_client_siren_active', $names);
|
||||
self::assertNotContains('uq_client_email_active', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{indexname: string, indexdef: string}>
|
||||
*/
|
||||
private function clientIndexes(): array
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var list<array{indexname: string, indexdef: string}> $rows */
|
||||
return $this->getEm()->getConnection()->fetchAllAssociative(
|
||||
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'client'",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Test fonctionnel du mode strict PATCH multi-groupes (RG-1.28) — ERP-60.
|
||||
*
|
||||
* Le cas est deja couvert en unitaire (ClientProcessorTest) ; on en ajoute la
|
||||
* preuve fonctionnelle HTTP, SANS dependre d'un role metier : un utilisateur
|
||||
* portant `commercial.clients.manage` mais PAS `commercial.clients.accounting.manage`
|
||||
* qui envoie un PATCH melant un champ principal (companyName) et un champ
|
||||
* comptable (siren) recoit un 403 sur l'ENSEMBLE du payload — aucun champ n'est
|
||||
* applique (pas de filtrage silencieux).
|
||||
*
|
||||
* ⚠ La matrice differenciee par role metier (Bureau / Compta / Commerciale) est
|
||||
* DELEGUEE a ERP-74 (#493). Ici on n'utilise qu'un user mono-permission.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientPatchStrictTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
|
||||
{
|
||||
$seed = $this->seedClient('Strict Mix');
|
||||
$credentials = $this->createUserWithPermission('commercial.clients.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'companyName' => 'Renamed Strict',
|
||||
'siren' => '123456789',
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.28 : 403 strict (le champ comptable siren exige accounting.manage).
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// Aucun champ applique : le companyName d'origine est intact.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(ClientEntity::class)->find($seed->getId());
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests de securite GENERIQUE de /api/clients (ERP-60).
|
||||
*
|
||||
* Couvre les garde-fous non dependants des roles metier :
|
||||
* - 401 si requete anonyme (firewall JWT) ;
|
||||
* - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`.
|
||||
*
|
||||
* ⚠ La matrice RBAC differenciee par role metier (bureau / compta / commerciale
|
||||
* / usine) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils
|
||||
* exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientSecurityTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
public function testAnonymousGetCollectionReturns401(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAnonymousGetItemReturns401(): void
|
||||
{
|
||||
$seed = $this->seedClient('Anon Item');
|
||||
$client = self::createClient();
|
||||
|
||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutClientsViewPermission(): void
|
||||
{
|
||||
// User authentifie portant une permission SANS rapport avec les clients.
|
||||
$seed = $this->seedClient('Forbidden Target');
|
||||
$credentials = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
// Collection.
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// Detail.
|
||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Tests d'unicite — combler les trous (ERP-60).
|
||||
*
|
||||
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par
|
||||
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
|
||||
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
|
||||
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientUniquenessTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
|
||||
* email principal — aucune contrainte d'unicite (un email peut servir
|
||||
* plusieurs clients).
|
||||
*/
|
||||
public function testDuplicateEmailIsAllowed(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$iri = '/api/categories/'.$cat->getId();
|
||||
|
||||
$payload = static fn (string $name): array => [
|
||||
'companyName' => $name,
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'partage@test.fr',
|
||||
'categories' => [$iri],
|
||||
];
|
||||
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Meme email, nom different -> doit passer (pas d'index unique email).
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
|
||||
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
|
||||
* seede donc directement via l'ORM et on prouve que le flush ne leve aucune
|
||||
* violation d'unicite.
|
||||
*/
|
||||
public function testDuplicateSirenIsAllowed(): void
|
||||
{
|
||||
// Boot kernel pour disposer de l'EM (pas d'appel HTTP necessaire ici).
|
||||
self::bootKernel();
|
||||
$em = $this->getEm();
|
||||
|
||||
$one = $this->seedClient('Siren Share One');
|
||||
$two = $this->seedClient('Siren Share Two');
|
||||
|
||||
$one->setSiren('123456789');
|
||||
$two->setSiren('123456789');
|
||||
$em->flush();
|
||||
|
||||
// Aucune exception : preuve qu'il n'existe pas d'index unique sur siren.
|
||||
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($one->getId())->getSiren());
|
||||
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($two->getId())->getSiren());
|
||||
}
|
||||
}
|
||||
@@ -134,17 +134,7 @@ final class ClientProcessorTest extends TestCase
|
||||
'isArchived' => false,
|
||||
],
|
||||
managed: true,
|
||||
// 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,
|
||||
],
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
@@ -163,69 +153,8 @@ 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, 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,
|
||||
],
|
||||
// geree : isArchived (non-null) y figure toujours.
|
||||
originalData: ['siren' => '123456789', 'isArchived' => false],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
@@ -308,34 +237,6 @@ final class ClientProcessorTest extends TestCase
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testCommercialeIncompleteInformationOnNonInformationPatchIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.04 durcie (ERP-74) : pour une Commerciale, la completude de
|
||||
// l'onglet Information est exigee meme quand le payload ne touche PAS
|
||||
// l'onglet Information (ici seulement companyName). L'ancienne condition
|
||||
// d'intersection avec INFORMATION_FIELDS a ete retiree.
|
||||
$client = $this->minimalClient();
|
||||
$client->setCompanyName('Renamed Co'); // onglet principal uniquement, Information vide
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.manage'],
|
||||
payload: ['companyName' => 'Renamed Co'],
|
||||
user: $this->commercialeUser(),
|
||||
managed: true,
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testNonCommercialeSkipsInformationCompleteness(): void
|
||||
{
|
||||
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
|
||||
|
||||
Reference in New Issue
Block a user