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